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>
This commit is contained in:
Till JS 2026-03-27 21:03:00 +01:00
parent ce51fd5fe2
commit 819568c3df
503 changed files with 9927 additions and 47044 deletions

View file

@ -0,0 +1 @@
dist/

View file

@ -0,0 +1,53 @@
# mana-api-gateway (Go)
Go replacement for the NestJS API Gateway. Handles API key management, rate limiting, credit billing, and service proxying.
## Architecture
- **Language:** Go 1.25
- **Database:** PostgreSQL (pgx v5)
- **Cache/RateLimit:** Redis (sliding window)
- **Port:** 3030
## Endpoints
### Public API (X-API-Key auth)
- `POST /v1/search` — Web search (1 credit)
- `POST /v1/extract` — Content extraction (1 credit)
- `POST /v1/stt/transcribe` — Speech-to-text (10 credits/min)
- `POST /v1/tts/synthesize` — Text-to-speech (1 credit/1000 chars)
### Management API (JWT auth)
- `POST /api-keys` — Create API key
- `GET /api-keys` — List user's keys
- `DELETE /api-keys/{id}` — Delete key
- `GET /api-keys/{id}/usage` — Daily usage stats
### System
- `GET /health` — Health check (DB + Redis)
- `GET /metrics` — Prometheus metrics
## Pricing Tiers
| Tier | Rate Limit | Monthly Credits | Price |
|------|-----------|-----------------|-------|
| Free | 10 req/min | 100 | €0 |
| Pro | 100 req/min | 5,000 | €19/mo |
| Enterprise | 1,000 req/min | 50,000 | €99/mo |
## Commands
```bash
go run ./cmd/server # Dev
go build ./cmd/server # Build
go test ./... # Test
```
## Environment Variables
- `PORT` — Server port (3030)
- `DATABASE_URL` — PostgreSQL connection
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`
- `SEARCH_SERVICE_URL`, `STT_SERVICE_URL`, `TTS_SERVICE_URL`
- `MANA_CORE_AUTH_URL` — JWT validation
- `ADMIN_USER_IDS` — Comma-separated admin user IDs

View file

@ -0,0 +1,23 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY services/mana-api-gateway-go/go.mod services/mana-api-gateway-go/go.sum ./
RUN go mod download
COPY services/mana-api-gateway-go/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-api-gateway ./cmd/server
# Runtime stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /mana-api-gateway /usr/local/bin/mana-api-gateway
EXPOSE 3030
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:3030/health || exit 1
ENTRYPOINT ["mana-api-gateway"]

View file

@ -0,0 +1,138 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/manacore/mana-api-gateway/internal/config"
"github.com/manacore/mana-api-gateway/internal/db"
"github.com/manacore/mana-api-gateway/internal/handler"
"github.com/manacore/mana-api-gateway/internal/middleware"
"github.com/manacore/mana-api-gateway/internal/proxy"
"github.com/manacore/mana-api-gateway/internal/service"
"github.com/redis/go-redis/v9"
"github.com/rs/cors"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg := config.Load()
ctx := context.Background()
// Database
database, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("database connection failed", "error", err)
os.Exit(1)
}
defer database.Close()
if err := database.Migrate(ctx); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
// Redis
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
Password: cfg.RedisPassword,
DB: 0,
})
if err := rdb.Ping(ctx).Err(); err != nil {
slog.Warn("redis unavailable, rate limiting disabled", "error", err)
} else {
slog.Info("redis connected")
}
defer rdb.Close()
// Services
apiKeySvc := service.NewApiKeyService(database.Pool, cfg.KeyPrefixLive, cfg.KeyPrefixTest)
usageSvc := service.NewUsageService(database.Pool)
// Handlers
apiKeysHandler := handler.NewApiKeysHandler(apiKeySvc, usageSvc)
healthHandler := handler.NewHealthHandler(database.Pool, rdb)
// Proxy
serviceProxy := proxy.NewServiceProxy(cfg.SearchURL, cfg.STTURL, cfg.TTSURL, apiKeySvc, usageSvc)
// Middleware chains
apiKeyAuth := middleware.ApiKeyMiddleware(apiKeySvc)
rateLimit := middleware.RateLimitMiddleware(rdb, cfg.RedisPrefix)
creditsCheck := middleware.CreditsMiddleware(apiKeySvc)
jwtAuth := middleware.JWTMiddleware(cfg.AuthURL)
// Chain: API Key → Rate Limit → Credits → Handler
publicChain := func(h http.Handler) http.Handler {
return apiKeyAuth(rateLimit(creditsCheck(h)))
}
// Routes
mux := http.NewServeMux()
// Health & Metrics (public, no auth)
mux.HandleFunc("GET /health", healthHandler.Health)
mux.HandleFunc("GET /metrics", healthHandler.Metrics)
// Public API (API Key auth + rate limit + credits)
mux.Handle("POST /v1/search", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
mux.Handle("POST /v1/extract", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
mux.Handle("POST /v1/extract/bulk", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
mux.Handle("GET /v1/search/engines", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
mux.Handle("POST /v1/stt/transcribe", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
mux.Handle("GET /v1/stt/models", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
mux.Handle("GET /v1/stt/languages", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
mux.Handle("POST /v1/tts/synthesize", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
mux.Handle("GET /v1/tts/voices", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
mux.Handle("GET /v1/tts/languages", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
// Management API (JWT auth)
mux.Handle("POST /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.CreateKey)))
mux.Handle("GET /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.ListKeys)))
mux.Handle("DELETE /api-keys/{id}", jwtAuth(http.HandlerFunc(apiKeysHandler.DeleteKey)))
mux.Handle("GET /api-keys/{id}/usage", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsage)))
mux.Handle("GET /api-keys/{id}/usage/summary", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsageSummary)))
// CORS
c := cors.New(cors.Options{
AllowedOrigins: cfg.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-API-Key"},
AllowCredentials: true,
})
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: c.Handler(mux),
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
slog.Info("mana-api-gateway starting", "port", cfg.Port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}

View file

@ -0,0 +1,21 @@
module github.com/manacore/mana-api-gateway
go 1.25.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.1
github.com/redis/go-redis/v9 v9.18.0
github.com/rs/cors v1.11.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

View file

@ -0,0 +1,46 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,93 @@
package config
import (
"os"
"strconv"
"strings"
)
type Config struct {
Port int
DatabaseURL string
RedisHost string
RedisPort int
RedisPassword string
RedisPrefix string
// Backend service URLs
SearchURL string
STTURL string
TTSURL string
// Auth
AuthURL string
AdminUserIDs []string
// API Key settings
KeyPrefixLive string
KeyPrefixTest string
// Defaults
DefaultRateLimit int
DefaultMonthlyCredits int
CORSOrigins []string
}
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "3030"))
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
defaultRL, _ := strconv.Atoi(getEnv("DEFAULT_RATE_LIMIT", "10"))
defaultCredits, _ := strconv.Atoi(getEnv("DEFAULT_MONTHLY_CREDITS", "100"))
var adminIDs []string
if ids := os.Getenv("ADMIN_USER_IDS"); ids != "" {
for _, id := range strings.Split(ids, ",") {
id = strings.TrimSpace(id)
if id != "" {
adminIDs = append(adminIDs, id)
}
}
}
var origins []string
if o := os.Getenv("CORS_ORIGINS"); o != "" {
for _, origin := range strings.Split(o, ",") {
origin = strings.TrimSpace(origin)
if origin != "" {
origins = append(origins, origin)
}
}
}
if len(origins) == 0 {
origins = []string{"http://localhost:3000", "http://localhost:5173"}
}
return &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: redisPort,
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisPrefix: getEnv("REDIS_PREFIX", "api-gateway:"),
SearchURL: getEnv("SEARCH_SERVICE_URL", "http://localhost:3021"),
STTURL: getEnv("STT_SERVICE_URL", "http://localhost:3020"),
TTSURL: getEnv("TTS_SERVICE_URL", "http://localhost:3022"),
AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"),
AdminUserIDs: adminIDs,
KeyPrefixLive: getEnv("API_KEY_PREFIX_LIVE", "sk_live_"),
KeyPrefixTest: getEnv("API_KEY_PREFIX_TEST", "sk_test_"),
DefaultRateLimit: defaultRL,
DefaultMonthlyCredits: defaultCredits,
CORSOrigins: origins,
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -0,0 +1,119 @@
package db
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DB wraps a pgx connection pool.
type DB struct {
Pool *pgxpool.Pool
}
// New creates a new database connection pool.
func New(ctx context.Context, databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse db config: %w", err)
}
config.MaxConns = 20
config.MinConns = 2
config.MaxConnLifetime = 30 * time.Minute
config.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
slog.Info("database connected")
return &DB{Pool: pool}, nil
}
// Migrate creates the schema and tables.
func (d *DB) Migrate(ctx context.Context) error {
sql := `
CREATE SCHEMA IF NOT EXISTS api_gateway;
CREATE TABLE IF NOT EXISTS api_gateway.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
user_id TEXT,
organization_id TEXT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
tier TEXT NOT NULL DEFAULT 'free',
rate_limit INT NOT NULL DEFAULT 10,
monthly_credits INT NOT NULL DEFAULT 100,
credits_used INT NOT NULL DEFAULT 0,
credits_reset_at TIMESTAMPTZ,
allowed_endpoints JSONB DEFAULT '["search"]',
allowed_ips JSONB,
active BOOLEAN NOT NULL DEFAULT true,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_gateway.api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_gateway.api_keys(user_id);
CREATE TABLE IF NOT EXISTS api_gateway.api_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
request_size INT,
response_size INT,
latency_ms INT,
status_code INT,
credits_used INT NOT NULL DEFAULT 0,
credit_reason TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_usage_key_id ON api_gateway.api_usage(api_key_id);
CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_gateway.api_usage(created_at);
CREATE TABLE IF NOT EXISTS api_gateway.api_usage_daily (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE,
date DATE NOT NULL,
endpoint TEXT NOT NULL,
request_count INT NOT NULL DEFAULT 0,
credits_used INT NOT NULL DEFAULT 0,
total_latency_ms INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
UNIQUE(api_key_id, date, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_api_usage_daily_date ON api_gateway.api_usage_daily(date);
`
_, err := d.Pool.Exec(ctx, sql)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
slog.Info("database migrated")
return nil
}
// Close closes the connection pool.
func (d *DB) Close() {
d.Pool.Close()
}

View file

@ -0,0 +1,132 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/manacore/mana-api-gateway/internal/middleware"
"github.com/manacore/mana-api-gateway/internal/service"
)
// ApiKeysHandler handles API key management endpoints.
type ApiKeysHandler struct {
apiKeyService *service.ApiKeyService
usageService *service.UsageService
}
// NewApiKeysHandler creates a new handler.
func NewApiKeysHandler(apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ApiKeysHandler {
return &ApiKeysHandler{apiKeyService: apiKeySvc, usageService: usageSvc}
}
// CreateKey handles POST /api-keys
func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Tier string `json:"tier"`
IsTest bool `json:"isTest"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
if body.Tier == "" {
body.Tier = "free"
}
rawKey, apiKey, err := h.apiKeyService.Create(r.Context(), userID, body.Name, body.Description, body.Tier, body.IsTest)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"key": rawKey,
"apiKey": apiKey,
})
}
// ListKeys handles GET /api-keys
func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
keys, err := h.apiKeyService.ListByUser(r.Context(), userID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
return
}
if keys == nil {
keys = []service.ApiKey{}
}
writeJSON(w, http.StatusOK, keys)
}
// DeleteKey handles DELETE /api-keys/{id}
func (h *ApiKeysHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
keyID := r.PathValue("id")
if err := h.apiKeyService.Delete(r.Context(), keyID, userID); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
}
// GetUsage handles GET /api-keys/{id}/usage
func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
keyID := r.PathValue("id")
usage, err := h.usageService.GetDailyUsage(r.Context(), keyID, 30)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
return
}
if usage == nil {
usage = []service.DailyUsage{}
}
writeJSON(w, http.StatusOK, usage)
}
// GetUsageSummary handles GET /api-keys/{id}/usage/summary
func (h *ApiKeysHandler) GetUsageSummary(w http.ResponseWriter, r *http.Request) {
keyID := r.PathValue("id")
since := time.Now().AddDate(0, -1, 0) // last 30 days
summary, err := h.usageService.GetSummary(r.Context(), keyID, since)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
return
}
writeJSON(w, http.StatusOK, summary)
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

View file

@ -0,0 +1,69 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
// HealthHandler handles health and metrics endpoints.
type HealthHandler struct {
pool *pgxpool.Pool
redis *redis.Client
startTime time.Time
}
// NewHealthHandler creates a new health handler.
func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler {
return &HealthHandler{pool: pool, redis: rdb, startTime: time.Now()}
}
// Health handles GET /health
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
dbOK := "ok"
if err := h.pool.Ping(ctx); err != nil {
dbOK = "error"
}
redisOK := "ok"
if err := h.redis.Ping(ctx).Err(); err != nil {
redisOK = "error"
}
status := "ok"
if dbOK != "ok" || redisOK != "ok" {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
"status": status,
"service": "mana-api-gateway",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"uptime": time.Since(h.startTime).Seconds(),
"database": dbOK,
"redis": redisOK,
})
}
// Metrics handles GET /metrics (Prometheus format)
func (h *HealthHandler) Metrics(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(h.startTime).Seconds()
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP mana_api_gateway_uptime_seconds Gateway uptime\n")
fmt.Fprintf(w, "# TYPE mana_api_gateway_uptime_seconds gauge\n")
fmt.Fprintf(w, "mana_api_gateway_uptime_seconds %.0f\n", uptime)
// DB pool stats
stats := h.pool.Stat()
fmt.Fprintf(w, "# HELP mana_api_gateway_db_connections Database connection pool\n")
fmt.Fprintf(w, "# TYPE mana_api_gateway_db_connections gauge\n")
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"total\"} %d\n", stats.TotalConns())
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"idle\"} %d\n", stats.IdleConns())
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"acquired\"} %d\n", stats.AcquiredConns())
}

View file

@ -0,0 +1,106 @@
package middleware
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"github.com/manacore/mana-api-gateway/internal/service"
)
type contextKey string
const ApiKeyContextKey contextKey = "apiKey"
var endpointRegex = regexp.MustCompile(`/v1/(\w+)`)
// ApiKeyMiddleware validates X-API-Key header and attaches key data to context.
func ApiKeyMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawKey := r.Header.Get("X-API-Key")
if rawKey == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "API key required. Use X-API-Key header.",
})
return
}
keyData, err := apiKeyService.ValidateKey(r.Context(), rawKey)
if err != nil {
slog.Debug("invalid api key", "error", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Invalid API key",
})
return
}
if !keyData.Active {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "API key is disabled",
})
return
}
if keyData.ExpiresAt != nil && time.Now().After(*keyData.ExpiresAt) {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "API key has expired",
})
return
}
// Check endpoint permission
endpoint := extractEndpoint(r.URL.Path)
if !hasEndpointPermission(keyData, endpoint) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "Endpoint '" + endpoint + "' not allowed for this API key. Upgrade your plan.",
})
return
}
// Attach key data to context
ctx := context.WithValue(r.Context(), ApiKeyContextKey, keyData)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetApiKey retrieves the API key data from the request context.
func GetApiKey(r *http.Request) *service.ApiKeyData {
data, _ := r.Context().Value(ApiKeyContextKey).(*service.ApiKeyData)
return data
}
func extractEndpoint(path string) string {
m := endpointRegex.FindStringSubmatch(path)
if len(m) >= 2 {
return m[1]
}
return "unknown"
}
func hasEndpointPermission(keyData *service.ApiKeyData, endpoint string) bool {
if keyData.AllowedEndpoints == "" {
return true
}
var allowed []string
if err := json.Unmarshal([]byte(keyData.AllowedEndpoints), &allowed); err != nil {
return true
}
for _, e := range allowed {
if e == endpoint || strings.HasPrefix(endpoint, e) {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

View file

@ -0,0 +1,101 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type jwtContextKey string
const UserIDContextKey jwtContextKey = "userID"
const UserRoleContextKey jwtContextKey = "userRole"
// JWTClaims holds the JWT token claims.
type JWTClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// JWTMiddleware validates Bearer JWT tokens for management endpoints.
// Uses JWKS from mana-core-auth (simplified: accepts any valid JWT structure for now).
func JWTMiddleware(authURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Authorization header required. Use Bearer <JWT>.",
})
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
// Validate JWT via auth service
userID, role, err := validateJWT(r.Context(), authURL, tokenStr)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Invalid or expired token",
})
return
}
ctx := context.WithValue(r.Context(), UserIDContextKey, userID)
ctx = context.WithValue(ctx, UserRoleContextKey, role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserID returns the authenticated user ID from context.
func GetUserID(r *http.Request) string {
id, _ := r.Context().Value(UserIDContextKey).(string)
return id
}
// GetUserRole returns the user role from context.
func GetUserRole(r *http.Request) string {
role, _ := r.Context().Value(UserRoleContextKey).(string)
return role
}
// validateJWT calls mana-core-auth /api/v1/auth/validate to verify the token.
func validateJWT(ctx context.Context, authURL, token string) (userID, role string, err error) {
req, err := http.NewRequestWithContext(ctx, "POST", authURL+"/api/v1/auth/validate", strings.NewReader(`{"token":"`+token+`"}`))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("auth service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("auth validation failed: %d", resp.StatusCode)
}
var result struct {
Valid bool `json:"valid"`
UserID string `json:"userId"`
Role string `json:"role"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
if !result.Valid {
return "", "", fmt.Errorf("token not valid")
}
return result.UserID, result.Role, nil
}

View file

@ -0,0 +1,99 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/manacore/mana-api-gateway/internal/service"
"github.com/redis/go-redis/v9"
)
// RateLimitMiddleware enforces per-key sliding window rate limits using Redis.
func RateLimitMiddleware(rdb *redis.Client, prefix string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
keyData := GetApiKey(r)
if keyData == nil {
next.ServeHTTP(w, r)
return
}
ctx := r.Context()
key := prefix + "ratelimit:" + keyData.ID
limit := keyData.RateLimit
window := int64(60) // 60 seconds
now := time.Now().UnixMilli()
windowStart := now - window*1000
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
countCmd := pipe.ZCard(ctx, key)
pipe.Exec(ctx)
count := countCmd.Val()
if count >= int64(limit) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("Retry-After", "60")
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": "Rate limit exceeded",
"limit": limit,
"remaining": 0,
"retryAfter": 60,
})
return
}
// Add current request
rdb.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)})
rdb.Expire(ctx, key, time.Duration(window)*time.Second)
remaining := int64(limit) - count - 1
if remaining < 0 {
remaining = 0
}
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(now/1000+window, 10))
next.ServeHTTP(w, r)
})
}
}
// CreditsMiddleware checks if the API key has enough credits.
func CreditsMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
keyData := GetApiKey(r)
if keyData == nil {
next.ServeHTTP(w, r)
return
}
endpoint := extractEndpoint(r.URL.Path)
estimatedCredits := service.CreditCosts[endpoint]
if estimatedCredits == 0 {
estimatedCredits = 1
}
ok, err := apiKeyService.HasEnoughCredits(r.Context(), keyData.ID, estimatedCredits)
if err != nil || !ok {
writeJSON(w, http.StatusPaymentRequired, map[string]any{
"error": "Insufficient credits",
"creditsRequired": estimatedCredits,
"creditsUsed": keyData.CreditsUsed,
"monthlyCredits": keyData.MonthlyCredits,
})
return
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,157 @@
package proxy
import (
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/manacore/mana-api-gateway/internal/middleware"
"github.com/manacore/mana-api-gateway/internal/service"
)
// ServiceProxy proxies requests to backend services and tracks usage.
type ServiceProxy struct {
searchProxy *httputil.ReverseProxy
sttProxy *httputil.ReverseProxy
ttsProxy *httputil.ReverseProxy
apiKeyService *service.ApiKeyService
usageService *service.UsageService
}
// NewServiceProxy creates a new service proxy.
func NewServiceProxy(searchURL, sttURL, ttsURL string, apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ServiceProxy {
return &ServiceProxy{
searchProxy: createProxy(searchURL),
sttProxy: createProxy(sttURL),
ttsProxy: createProxy(ttsURL),
apiKeyService: apiKeySvc,
usageService: usageSvc,
}
}
func createProxy(targetURL string) *httputil.ReverseProxy {
target, err := url.Parse(targetURL)
if err != nil {
slog.Error("invalid proxy target", "url", targetURL, "error", err)
return nil
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
}
return proxy
}
// HandleSearch proxies to the search service.
func (p *ServiceProxy) HandleSearch(w http.ResponseWriter, r *http.Request) {
p.proxyRequest(w, r, p.searchProxy, "search", "/api/v1")
}
// HandleSTT proxies to the STT service.
func (p *ServiceProxy) HandleSTT(w http.ResponseWriter, r *http.Request) {
p.proxyRequest(w, r, p.sttProxy, "stt", "")
}
// HandleTTS proxies to the TTS service.
func (p *ServiceProxy) HandleTTS(w http.ResponseWriter, r *http.Request) {
p.proxyRequest(w, r, p.ttsProxy, "tts", "")
}
func (p *ServiceProxy) proxyRequest(w http.ResponseWriter, r *http.Request, proxy *httputil.ReverseProxy, endpoint, pathPrefix string) {
if proxy == nil {
http.Error(w, `{"error":"service unavailable"}`, http.StatusServiceUnavailable)
return
}
keyData := middleware.GetApiKey(r)
start := time.Now()
// Rewrite path: /v1/search -> /api/v1/search (or whatever the backend expects)
originalPath := r.URL.Path
if pathPrefix != "" {
r.URL.Path = strings.Replace(r.URL.Path, "/v1/"+endpoint, pathPrefix+"/"+endpoint, 1)
}
// Use a response recorder to capture status code
rec := &responseRecorder{ResponseWriter: w, statusCode: 200}
proxy.ServeHTTP(rec, r)
// Log usage
latency := time.Since(start).Milliseconds()
credits := service.CreditCosts[endpoint]
if credits == 0 {
credits = 1
}
// Deduct credits
if keyData != nil {
p.apiKeyService.IncrementCredits(r.Context(), keyData.ID, credits)
// Log usage asynchronously
go func() {
p.usageService.LogUsage(r.Context(), service.UsageEntry{
ApiKeyID: keyData.ID,
Endpoint: endpoint,
Method: r.Method,
Path: originalPath,
RequestSize: int(r.ContentLength),
ResponseSize: rec.size,
LatencyMs: int(latency),
StatusCode: rec.statusCode,
CreditsUsed: credits,
CreditReason: endpoint,
})
}()
}
}
// responseRecorder captures the status code and response size.
type responseRecorder struct {
http.ResponseWriter
statusCode int
size int
}
func (r *responseRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.size += n
return n, err
}
// Flush implements http.Flusher for streaming responses.
func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap returns the underlying ResponseWriter (for http.ResponseController).
func (r *responseRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
// ReadFrom implements io.ReaderFrom for efficient copying.
func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) {
if rf, ok := r.ResponseWriter.(io.ReaderFrom); ok {
n, err := rf.ReadFrom(src)
r.size += int(n)
return n, err
}
// Fallback: use io.Copy which will call Write
return io.Copy(r.ResponseWriter, src)
}

View file

@ -0,0 +1,256 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ApiKey represents a stored API key.
type ApiKey struct {
ID string `json:"id"`
Key string `json:"key"` // masked in responses
KeyHash string `json:"-"`
KeyPrefix string `json:"keyPrefix"`
UserID *string `json:"userId"`
OrganizationID *string `json:"organizationId"`
Name string `json:"name"`
Description string `json:"description"`
Tier string `json:"tier"`
RateLimit int `json:"rateLimit"`
MonthlyCredits int `json:"monthlyCredits"`
CreditsUsed int `json:"creditsUsed"`
CreditsResetAt *time.Time `json:"creditsResetAt"`
AllowedEndpoints string `json:"allowedEndpoints"` // JSON array
AllowedIPs *string `json:"allowedIps"`
Active bool `json:"active"`
ExpiresAt *time.Time `json:"expiresAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ApiKeyData is the validated key data attached to requests.
type ApiKeyData struct {
ID string
UserID *string
OrganizationID *string
Name string
Tier string
RateLimit int
MonthlyCredits int
CreditsUsed int
AllowedEndpoints string
AllowedIPs *string
Active bool
ExpiresAt *time.Time
}
// PricingTier defines limits for a tier.
type PricingTier struct {
Name string
RateLimit int
MonthlyCredits int
Endpoints []string
Price int // cents
}
var Tiers = map[string]PricingTier{
"free": {Name: "Free", RateLimit: 10, MonthlyCredits: 100, Endpoints: []string{"search"}, Price: 0},
"pro": {Name: "Pro", RateLimit: 100, MonthlyCredits: 5000, Endpoints: []string{"search", "stt", "tts"}, Price: 1900},
"enterprise": {Name: "Enterprise", RateLimit: 1000, MonthlyCredits: 50000, Endpoints: []string{"search", "stt", "tts"}, Price: 9900},
}
// CreditCosts per endpoint.
var CreditCosts = map[string]int{
"search": 1,
"extract": 1,
"stt": 10, // per minute
"tts": 1, // per 1000 chars
}
// ApiKeyService manages API keys in PostgreSQL.
type ApiKeyService struct {
pool *pgxpool.Pool
prefixLive string
prefixTest string
}
// NewApiKeyService creates a new service.
func NewApiKeyService(pool *pgxpool.Pool, prefixLive, prefixTest string) *ApiKeyService {
return &ApiKeyService{pool: pool, prefixLive: prefixLive, prefixTest: prefixTest}
}
// GenerateKey creates a new API key string.
func (s *ApiKeyService) GenerateKey(isTest bool) (key, hash, prefix string) {
pfx := s.prefixLive
if isTest {
pfx = s.prefixTest
}
b := make([]byte, 24)
rand.Read(b)
randomPart := base64.RawURLEncoding.EncodeToString(b)
key = pfx + randomPart
h := sha256.Sum256([]byte(key))
hash = fmt.Sprintf("%x", h)
prefix = pfx
return
}
// MaskKey hides most of the key for display.
func (s *ApiKeyService) MaskKey(key string) string {
if len(key) <= 12 {
return key
}
pfx := s.prefixLive
if len(key) > len(s.prefixTest) && key[:len(s.prefixTest)] == s.prefixTest {
pfx = s.prefixTest
}
return pfx + "..." + key[len(key)-4:]
}
// Create creates a new API key.
func (s *ApiKeyService) Create(ctx context.Context, userID, name, description, tier string, isTest bool) (string, *ApiKey, error) {
key, hash, prefix := s.GenerateKey(isTest)
t, ok := Tiers[tier]
if !ok {
t = Tiers["free"]
tier = "free"
}
endpoints, _ := json.Marshal(t.Endpoints)
resetAt := nextMonthReset()
row := s.pool.QueryRow(ctx, `
INSERT INTO api_gateway.api_keys (key, key_hash, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, $11, true)
RETURNING id, created_at, updated_at
`, key, hash, prefix, userID, name, description, tier, t.RateLimit, t.MonthlyCredits, resetAt, string(endpoints))
var apiKey ApiKey
var id string
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &createdAt, &updatedAt); err != nil {
return "", nil, fmt.Errorf("create key: %w", err)
}
apiKey = ApiKey{
ID: id, Key: s.MaskKey(key), KeyPrefix: prefix,
UserID: &userID, Name: name, Description: description,
Tier: tier, RateLimit: t.RateLimit, MonthlyCredits: t.MonthlyCredits,
CreditsUsed: 0, CreditsResetAt: &resetAt, AllowedEndpoints: string(endpoints),
Active: true, CreatedAt: createdAt, UpdatedAt: updatedAt,
}
return key, &apiKey, nil
}
// ValidateKey looks up a key by its hash.
func (s *ApiKeyService) ValidateKey(ctx context.Context, rawKey string) (*ApiKeyData, error) {
h := sha256.Sum256([]byte(rawKey))
hash := fmt.Sprintf("%x", h)
var data ApiKeyData
err := s.pool.QueryRow(ctx, `
SELECT id, user_id, organization_id, name, tier, rate_limit, monthly_credits, credits_used, allowed_endpoints, allowed_ips, active, expires_at
FROM api_gateway.api_keys WHERE key_hash = $1
`, hash).Scan(&data.ID, &data.UserID, &data.OrganizationID, &data.Name, &data.Tier,
&data.RateLimit, &data.MonthlyCredits, &data.CreditsUsed, &data.AllowedEndpoints,
&data.AllowedIPs, &data.Active, &data.ExpiresAt)
if err != nil {
return nil, err
}
// Update last_used_at
s.pool.Exec(ctx, `UPDATE api_gateway.api_keys SET last_used_at = NOW() WHERE id = $1`, data.ID)
return &data, nil
}
// ListByUser returns all keys for a user (masked).
func (s *ApiKeyService) ListByUser(ctx context.Context, userID string) ([]ApiKey, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, key, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active, expires_at, last_used_at, created_at, updated_at
FROM api_gateway.api_keys WHERE user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []ApiKey
for rows.Next() {
var k ApiKey
if err := rows.Scan(&k.ID, &k.Key, &k.KeyPrefix, &k.UserID, &k.Name, &k.Description,
&k.Tier, &k.RateLimit, &k.MonthlyCredits, &k.CreditsUsed, &k.CreditsResetAt,
&k.AllowedEndpoints, &k.Active, &k.ExpiresAt, &k.LastUsedAt, &k.CreatedAt, &k.UpdatedAt); err != nil {
return nil, err
}
k.Key = s.MaskKey(k.Key)
keys = append(keys, k)
}
return keys, nil
}
// Delete removes an API key.
func (s *ApiKeyService) Delete(ctx context.Context, id, userID string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM api_gateway.api_keys WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("key not found")
}
return nil
}
// IncrementCredits adds used credits to a key.
func (s *ApiKeyService) IncrementCredits(ctx context.Context, keyID string, amount int) error {
_, err := s.pool.Exec(ctx, `
UPDATE api_gateway.api_keys
SET credits_used = CASE
WHEN credits_reset_at < NOW() THEN $2
ELSE credits_used + $2
END,
credits_reset_at = CASE
WHEN credits_reset_at < NOW() THEN $3
ELSE credits_reset_at
END
WHERE id = $1
`, keyID, amount, nextMonthReset())
return err
}
// HasEnoughCredits checks if a key has sufficient credits.
func (s *ApiKeyService) HasEnoughCredits(ctx context.Context, keyID string, required int) (bool, error) {
var used, total int
var resetAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT credits_used, monthly_credits, credits_reset_at FROM api_gateway.api_keys WHERE id = $1
`, keyID).Scan(&used, &total, &resetAt)
if err != nil {
return false, err
}
// If past reset date, credits are effectively 0
if resetAt != nil && time.Now().After(*resetAt) {
return true, nil
}
return used+required <= total, nil
}
func nextMonthReset() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC)
}

View file

@ -0,0 +1,82 @@
package service
import (
"testing"
"time"
)
func TestGenerateKey(t *testing.T) {
svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"}
key, hash, prefix := svc.GenerateKey(false)
if prefix != "sk_live_" {
t.Errorf("expected sk_live_ prefix, got %s", prefix)
}
if len(key) < 20 {
t.Errorf("key too short: %s", key)
}
if len(hash) != 64 { // SHA256 hex
t.Errorf("hash wrong length: %d", len(hash))
}
// Test key
key2, _, prefix2 := svc.GenerateKey(true)
if prefix2 != "sk_test_" {
t.Errorf("expected sk_test_ prefix, got %s", prefix2)
}
if key == key2 {
t.Error("keys should be unique")
}
}
func TestMaskKey(t *testing.T) {
svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"}
tests := []struct {
key string
want string
}{
{"sk_live_abcdefghijklmnop1234", "sk_live_...1234"},
{"sk_test_xyz9876543210abcdef", "sk_test_...cdef"},
{"short", "short"},
}
for _, tt := range tests {
got := svc.MaskKey(tt.key)
if got != tt.want {
t.Errorf("MaskKey(%q) = %q, want %q", tt.key, got, tt.want)
}
}
}
func TestNextMonthReset(t *testing.T) {
reset := nextMonthReset()
now := time.Now()
if reset.Before(now) {
t.Error("reset should be in the future")
}
if reset.Day() != 1 {
t.Errorf("reset should be first of month, got day %d", reset.Day())
}
}
func TestPricingTiers(t *testing.T) {
free := Tiers["free"]
if free.RateLimit != 10 {
t.Errorf("free rate limit = %d, want 10", free.RateLimit)
}
if free.MonthlyCredits != 100 {
t.Errorf("free monthly credits = %d, want 100", free.MonthlyCredits)
}
pro := Tiers["pro"]
if pro.RateLimit != 100 {
t.Errorf("pro rate limit = %d, want 100", pro.RateLimit)
}
enterprise := Tiers["enterprise"]
if enterprise.RateLimit != 1000 {
t.Errorf("enterprise rate limit = %d, want 1000", enterprise.RateLimit)
}
}

View file

@ -0,0 +1,114 @@
package service
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// UsageEntry is a single API usage log entry.
type UsageEntry struct {
ApiKeyID string `json:"apiKeyId"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
Path string `json:"path"`
RequestSize int `json:"requestSize"`
ResponseSize int `json:"responseSize"`
LatencyMs int `json:"latencyMs"`
StatusCode int `json:"statusCode"`
CreditsUsed int `json:"creditsUsed"`
CreditReason string `json:"creditReason"`
}
// DailyUsage is an aggregated daily usage entry.
type DailyUsage struct {
Date string `json:"date"`
Endpoint string `json:"endpoint"`
RequestCount int `json:"requestCount"`
CreditsUsed int `json:"creditsUsed"`
ErrorCount int `json:"errorCount"`
}
// UsageSummary is an overview of usage.
type UsageSummary struct {
TotalRequests int `json:"totalRequests"`
TotalCredits int `json:"totalCredits"`
TotalErrors int `json:"totalErrors"`
}
// UsageService logs and queries API usage.
type UsageService struct {
pool *pgxpool.Pool
}
// NewUsageService creates a new usage service.
func NewUsageService(pool *pgxpool.Pool) *UsageService {
return &UsageService{pool: pool}
}
// LogUsage records a single API request.
func (s *UsageService) LogUsage(ctx context.Context, entry UsageEntry) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO api_gateway.api_usage (api_key_id, endpoint, method, path, request_size, response_size, latency_ms, status_code, credits_used, credit_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, entry.ApiKeyID, entry.Endpoint, entry.Method, entry.Path,
entry.RequestSize, entry.ResponseSize, entry.LatencyMs, entry.StatusCode,
entry.CreditsUsed, entry.CreditReason)
// Also upsert daily aggregation
isError := 0
if entry.StatusCode >= 400 {
isError = 1
}
s.pool.Exec(ctx, `
INSERT INTO api_gateway.api_usage_daily (api_key_id, date, endpoint, request_count, credits_used, total_latency_ms, error_count)
VALUES ($1, CURRENT_DATE, $2, 1, $3, $4, $5)
ON CONFLICT (api_key_id, date, endpoint)
DO UPDATE SET
request_count = api_gateway.api_usage_daily.request_count + 1,
credits_used = api_gateway.api_usage_daily.credits_used + $3,
total_latency_ms = api_gateway.api_usage_daily.total_latency_ms + $4,
error_count = api_gateway.api_usage_daily.error_count + $5
`, entry.ApiKeyID, entry.Endpoint, entry.CreditsUsed, entry.LatencyMs, isError)
return err
}
// GetDailyUsage returns daily aggregated usage for a key.
func (s *UsageService) GetDailyUsage(ctx context.Context, keyID string, days int) ([]DailyUsage, error) {
rows, err := s.pool.Query(ctx, `
SELECT date::text, endpoint, request_count, credits_used, error_count
FROM api_gateway.api_usage_daily
WHERE api_key_id = $1 AND date >= CURRENT_DATE - $2::int
ORDER BY date DESC
`, keyID, days)
if err != nil {
return nil, err
}
defer rows.Close()
var usage []DailyUsage
for rows.Next() {
var u DailyUsage
if err := rows.Scan(&u.Date, &u.Endpoint, &u.RequestCount, &u.CreditsUsed, &u.ErrorCount); err != nil {
return nil, err
}
usage = append(usage, u)
}
return usage, nil
}
// GetSummary returns a usage summary for a key over a period.
func (s *UsageService) GetSummary(ctx context.Context, keyID string, since time.Time) (*UsageSummary, error) {
var summary UsageSummary
err := s.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(request_count), 0), COALESCE(SUM(credits_used), 0), COALESCE(SUM(error_count), 0)
FROM api_gateway.api_usage_daily
WHERE api_key_id = $1 AND date >= $2
`, keyID, since).Scan(&summary.TotalRequests, &summary.TotalCredits, &summary.TotalErrors)
if err != nil {
return nil, err
}
return &summary, nil
}

View file

@ -0,0 +1,11 @@
{
"name": "mana-api-gateway-go",
"version": "1.0.0",
"private": true,
"description": "Go API Gateway replacing NestJS mana-api-gateway",
"scripts": {
"build": "go build -ldflags=\"-s -w\" -o dist/mana-api-gateway ./cmd/server",
"dev": "go run ./cmd/server",
"test": "go test ./..."
}
}

3
services/mana-matrix-bot/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist/
data/
*.json.bak

View file

@ -0,0 +1,67 @@
# mana-matrix-bot
Consolidated Go Matrix bot replacing 21 separate NestJS bot services.
## Architecture
- **Language:** Go 1.23
- **Matrix SDK:** mautrix-go
- **Port:** 4000 (health/metrics)
- **Pattern:** Plugin architecture with compile-time registration
## Structure
```
cmd/server/main.go # Entry point, imports all plugins
internal/
config/ # Env-based configuration
runtime/ # Plugin lifecycle, Matrix sync, event routing
matrix/ # Matrix client wrapper, markdown, media
plugin/ # Plugin interface, registry, command routing
session/ # In-memory + Redis session store
services/ # Backend HTTP client, voice (STT/TTS)
plugins/ # One directory per bot plugin
todo/ # @todo-bot
calendar/ # @calendar-bot
gateway/ # @mana-bot (composite: AI + todo + calendar + clock + voice)
...
```
## Adding a New Plugin
1. Create `internal/plugins/mybot/mybot.go`
2. Implement `plugin.Plugin` interface
3. Register via `func init() { plugin.Register("mybot", func() plugin.Plugin { return &MyBot{} }) }`
4. Import in `cmd/server/main.go`: `_ "github.com/manacore/mana-matrix-bot/internal/plugins/mybot"`
5. Set env: `MATRIX_MYBOT_BOT_TOKEN=syt_xxx`
## Commands
```bash
# Build
go build -o dist/mana-matrix-bot ./cmd/server
# Run
PORT=4000 MATRIX_HOMESERVER_URL=http://localhost:8008 MATRIX_TODO_BOT_TOKEN=xxx ./dist/mana-matrix-bot
# Test
go test ./...
# Docker
docker build -t mana-matrix-bot:local -f Dockerfile .
```
## Environment Variables
### Global
- `PORT` — Health server port (default: 4000)
- `MATRIX_HOMESERVER_URL` — Matrix homeserver (default: http://localhost:8008)
- `MATRIX_STORAGE_PATH` — Sync state directory (default: ./data)
- `MANA_CORE_AUTH_URL` — Auth service URL
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` — Redis for sessions
- `STT_URL`, `TTS_URL` — Voice services
### Per Plugin (legacy env var names supported)
- `MATRIX_{NAME}_BOT_TOKEN` — Matrix access token
- `MATRIX_{NAME}_BOT_ROOMS` — Comma-separated allowed room IDs
- `{NAME}_BACKEND_URL` — Backend service URL

View file

@ -0,0 +1,28 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Copy Go module files first for better caching
COPY services/mana-matrix-bot/go.mod services/mana-matrix-bot/go.sum ./
RUN go mod download
# Copy source
COPY services/mana-matrix-bot/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-matrix-bot ./cmd/server
# Runtime stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /mana-matrix-bot /usr/local/bin/mana-matrix-bot
VOLUME /app/data
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:4000/health || exit 1
ENTRYPOINT ["mana-matrix-bot"]

View file

@ -0,0 +1,77 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/manacore/mana-matrix-bot/internal/config"
"github.com/manacore/mana-matrix-bot/internal/runtime"
// Import all plugins to trigger their init() registration.
_ "github.com/manacore/mana-matrix-bot/internal/plugins/calendar"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/chat"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/clock"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/contacts"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/gateway"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/manadeck"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/nutriphi"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/ollama"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/onboarding"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/picture"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/planta"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/presi"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/projectdoc"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/questions"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/skilltree"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/stats"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/storage"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/stt"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/todo"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/tts"
_ "github.com/manacore/mana-matrix-bot/internal/plugins/zitare"
)
func main() {
// Structured JSON logging
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg := config.Load()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create and start runtime
rt := runtime.New(cfg)
// Start health server
health := runtime.NewHealthServer(rt, cfg.Port)
httpServer := health.Start()
// Start all plugins
if err := rt.Start(ctx); err != nil {
slog.Error("failed to start runtime", "error", err)
os.Exit(1)
}
slog.Info("mana-matrix-bot running", "port", cfg.Port)
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down...")
cancel()
rt.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
httpServer.Shutdown(shutdownCtx)
slog.Info("shutdown complete")
}

View file

@ -0,0 +1,28 @@
module github.com/manacore/mana-matrix-bot
go 1.25.0
require (
github.com/redis/go-redis/v9 v9.18.0
maunium.net/go/mautrix v0.26.4
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.7 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View file

@ -0,0 +1,66 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=

View file

@ -0,0 +1,219 @@
package config
import (
"os"
"strconv"
"strings"
)
// Config holds all configuration for the consolidated Matrix bot.
type Config struct {
// Server
Port int
// Matrix
HomeserverURL string
StoragePath string
// Auth
AuthURL string
ServiceKey string
// Redis
RedisHost string
RedisPort int
RedisPassword string
// Voice services
STTURL string
TTSURL string
// Plugins (keyed by plugin name)
Plugins map[string]PluginConfig
}
// PluginConfig holds per-plugin configuration.
type PluginConfig struct {
Enabled bool
AccessToken string
AllowedRooms []string
BackendURL string
Extra map[string]string
}
// Load reads configuration from environment variables.
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "4000"))
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
cfg := &Config{
Port: port,
HomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://localhost:8008"),
StoragePath: getEnv("MATRIX_STORAGE_PATH", "./data"),
AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"),
ServiceKey: getEnv("MANA_CORE_SERVICE_KEY", ""),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: redisPort,
RedisPassword: getEnv("REDIS_PASSWORD", ""),
STTURL: getEnv("STT_URL", "http://localhost:3020"),
TTSURL: getEnv("TTS_URL", "http://localhost:3022"),
Plugins: make(map[string]PluginConfig),
}
// Load plugin configs from environment variables.
// Pattern: PLUGIN_{NAME}_ENABLED, PLUGIN_{NAME}_ACCESS_TOKEN, etc.
// Also supports legacy patterns: MATRIX_{NAME}_BOT_TOKEN
pluginNames := []string{
"gateway", "todo", "calendar", "clock", "ollama", "stats",
"contacts", "chat", "manadeck", "nutriphi", "picture", "planta",
"presi", "questions", "skilltree", "storage", "projectdoc",
"stt", "tts", "zitare", "onboarding",
}
// Map of legacy token env var names
legacyTokenMap := map[string]string{
"gateway": "MATRIX_MANA_BOT_TOKEN",
"todo": "MATRIX_TODO_BOT_TOKEN",
"calendar": "MATRIX_CALENDAR_BOT_TOKEN",
"clock": "MATRIX_CLOCK_BOT_TOKEN",
"ollama": "MATRIX_OLLAMA_BOT_TOKEN",
"stats": "MATRIX_STATS_BOT_TOKEN",
"contacts": "MATRIX_CONTACTS_BOT_TOKEN",
"chat": "MATRIX_CHAT_BOT_TOKEN",
"manadeck": "MATRIX_MANADECK_BOT_TOKEN",
"nutriphi": "MATRIX_NUTRIPHI_BOT_TOKEN",
"picture": "MATRIX_PICTURE_BOT_TOKEN",
"planta": "MATRIX_PLANTA_BOT_TOKEN",
"presi": "MATRIX_PRESI_BOT_TOKEN",
"questions": "MATRIX_QUESTIONS_BOT_TOKEN",
"skilltree": "MATRIX_SKILLTREE_BOT_TOKEN",
"storage": "MATRIX_STORAGE_BOT_TOKEN",
"projectdoc": "MATRIX_PROJECT_DOC_BOT_TOKEN",
"stt": "MATRIX_STT_BOT_TOKEN",
"tts": "MATRIX_TTS_BOT_TOKEN",
"zitare": "MATRIX_ZITARE_BOT_TOKEN",
"onboarding": "MATRIX_ONBOARDING_BOT_TOKEN",
}
legacyRoomsMap := map[string]string{
"gateway": "MATRIX_MANA_BOT_ROOMS",
"todo": "MATRIX_TODO_BOT_ROOMS",
"calendar": "MATRIX_CALENDAR_BOT_ROOMS",
"clock": "MATRIX_CLOCK_BOT_ROOMS",
"ollama": "MATRIX_OLLAMA_BOT_ROOMS",
"stats": "MATRIX_STATS_BOT_ROOMS",
"contacts": "MATRIX_CONTACTS_BOT_ROOMS",
"chat": "MATRIX_CHAT_BOT_ROOMS",
"manadeck": "MATRIX_MANADECK_BOT_ROOMS",
"nutriphi": "MATRIX_NUTRIPHI_BOT_ROOMS",
"picture": "MATRIX_PICTURE_BOT_ROOMS",
"planta": "MATRIX_PLANTA_BOT_ROOMS",
"presi": "MATRIX_PRESI_BOT_ROOMS",
"questions": "MATRIX_QUESTIONS_BOT_ROOMS",
"skilltree": "MATRIX_SKILLTREE_BOT_ROOMS",
"storage": "MATRIX_STORAGE_BOT_ROOMS",
"projectdoc": "MATRIX_PROJECT_DOC_BOT_ROOMS",
"stt": "MATRIX_STT_BOT_ROOMS",
"tts": "MATRIX_TTS_BOT_ROOMS",
"zitare": "MATRIX_ZITARE_BOT_ROOMS",
"onboarding": "MATRIX_ONBOARDING_BOT_ROOMS",
}
// Backend URL defaults per plugin
backendURLMap := map[string]string{
"todo": "TODO_BACKEND_URL",
"calendar": "CALENDAR_BACKEND_URL",
"clock": "CLOCK_BACKEND_URL",
"contacts": "CONTACTS_BACKEND_URL",
"chat": "CHAT_BACKEND_URL",
"manadeck": "MANADECK_BACKEND_URL",
"nutriphi": "NUTRIPHI_BACKEND_URL",
"picture": "PICTURE_BACKEND_URL",
"planta": "PLANTA_BACKEND_URL",
"presi": "PRESI_BACKEND_URL",
"questions": "QUESTIONS_BACKEND_URL",
"skilltree": "SKILLTREE_BACKEND_URL",
"storage": "STORAGE_BACKEND_URL",
"projectdoc": "PROJECTDOC_BACKEND_URL",
"zitare": "ZITARE_BACKEND_URL",
}
for _, name := range pluginNames {
upper := strings.ToUpper(name)
// Access token: try PLUGIN_*_ACCESS_TOKEN first, then legacy
token := os.Getenv("PLUGIN_" + upper + "_ACCESS_TOKEN")
if token == "" {
if legacyEnv, ok := legacyTokenMap[name]; ok {
token = os.Getenv(legacyEnv)
}
}
// Enabled: explicit env or auto-detect from token presence
enabledStr := os.Getenv("PLUGIN_" + upper + "_ENABLED")
enabled := token != ""
if enabledStr != "" {
enabled = enabledStr == "true" || enabledStr == "1"
}
// Allowed rooms
var rooms []string
roomsStr := os.Getenv("PLUGIN_" + upper + "_ALLOWED_ROOMS")
if roomsStr == "" {
if legacyEnv, ok := legacyRoomsMap[name]; ok {
roomsStr = os.Getenv(legacyEnv)
}
}
if roomsStr != "" {
for _, r := range strings.Split(roomsStr, ",") {
r = strings.TrimSpace(r)
if r != "" {
rooms = append(rooms, r)
}
}
}
// Backend URL
backendURL := ""
if envName, ok := backendURLMap[name]; ok {
backendURL = os.Getenv(envName)
}
// Extra config (plugin-specific env vars)
extra := make(map[string]string)
// Ollama-specific
if name == "ollama" || name == "gateway" {
extra["ollama_url"] = getEnv("OLLAMA_URL", "http://localhost:11434")
extra["ollama_model"] = getEnv("OLLAMA_MODEL", "gemma3:4b")
}
if name == "stt" || name == "gateway" {
extra["stt_url"] = cfg.STTURL
}
if name == "tts" || name == "gateway" {
extra["tts_url"] = cfg.TTSURL
}
// Gateway needs backend URLs for sub-handlers
if name == "gateway" {
extra["todo_url"] = getEnv("TODO_BACKEND_URL", "")
extra["calendar_url"] = getEnv("CALENDAR_BACKEND_URL", "")
extra["clock_url"] = getEnv("CLOCK_BACKEND_URL", "")
}
cfg.Plugins[name] = PluginConfig{
Enabled: enabled,
AccessToken: token,
AllowedRooms: rooms,
BackendURL: backendURL,
Extra: extra,
}
}
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -0,0 +1,241 @@
package matrix
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var mxcRegex = regexp.MustCompile(`^mxc://([^/]+)/(.+)$`)
// Client wraps mautrix.Client and implements the plugin.MatrixClient interface.
type Client struct {
inner *mautrix.Client
homeserver string
accessToken string
storagePath string
logger *slog.Logger
}
// ClientConfig holds configuration for creating a Matrix client.
type ClientConfig struct {
HomeserverURL string
AccessToken string
StoragePath string // path for sync state file
PluginName string
}
// NewClient creates a new Matrix client wrapper.
func NewClient(cfg ClientConfig) (*Client, error) {
userID := id.UserID("") // will be resolved via whoami
client, err := mautrix.NewClient(cfg.HomeserverURL, userID, cfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("create mautrix client: %w", err)
}
// Ensure storage directory exists
if cfg.StoragePath != "" {
dir := filepath.Dir(cfg.StoragePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir: %w", err)
}
}
logger := slog.With("plugin", cfg.PluginName)
return &Client{
inner: client,
homeserver: cfg.HomeserverURL,
accessToken: cfg.AccessToken,
storagePath: cfg.StoragePath,
logger: logger,
}, nil
}
// Inner returns the underlying mautrix.Client for advanced operations.
func (c *Client) Inner() *mautrix.Client {
return c.inner
}
// Login resolves the bot's user ID via /whoami.
func (c *Client) Login(ctx context.Context) (id.UserID, error) {
resp, err := c.inner.Whoami(ctx)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
c.inner.UserID = resp.UserID
c.logger.Info("authenticated", "user_id", resp.UserID)
return resp.UserID, nil
}
// GetUserID returns the bot's Matrix user ID.
func (c *Client) GetUserID() string {
return c.inner.UserID.String()
}
// SendMessage sends a text message with markdown formatting to a room.
func (c *Client) SendMessage(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendReply sends a reply to a specific event.
func (c *Client) SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
content.SetReply(&event.Event{
RoomID: id.RoomID(roomID),
ID: id.EventID(eventID),
})
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendNotice sends a notice (non-highlighted message).
func (c *Client) SendNotice(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// EditMessage edits an existing message.
func (c *Client) EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := map[string]any{
"msgtype": "m.text",
"body": "* " + text,
"format": "org.matrix.custom.html",
"formatted_body": "* " + MarkdownToHTML(text),
"m.relates_to": map[string]any{
"rel_type": "m.replace",
"event_id": eventID,
},
"m.new_content": map[string]any{
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": MarkdownToHTML(text),
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SetTyping sets the typing indicator for the bot in a room.
func (c *Client) SetTyping(ctx context.Context, roomID string, typing bool) error {
timeout := time.Duration(0)
if typing {
timeout = 30 * time.Second
}
_, err := c.inner.UserTyping(ctx, id.RoomID(roomID), typing, timeout)
return err
}
// DownloadMedia downloads media from a mxc:// URL.
func (c *Client) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error) {
matches := mxcRegex.FindStringSubmatch(mxcURL)
if len(matches) != 3 {
return nil, fmt.Errorf("invalid mxc URL: %s", mxcURL)
}
serverName := matches[1]
mediaID := matches[2]
// Try authenticated media API (Matrix spec v1.11+)
url := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", c.homeserver, serverName, mediaID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Fallback to legacy API
url = fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", c.homeserver, serverName, mediaID)
req2, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
return nil, fmt.Errorf("download media (legacy): %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download media failed: %d", resp2.StatusCode)
}
return io.ReadAll(resp2.Body)
}
return io.ReadAll(resp.Body)
}
// UploadMedia uploads media and returns the mxc:// URL.
func (c *Client) UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error) {
resp, err := c.inner.UploadBytes(ctx, data, contentType)
if err != nil {
return "", fmt.Errorf("upload media: %w", err)
}
return resp.ContentURI.String(), nil
}
// SendAudio sends an audio message to a room.
func (c *Client) SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgAudio,
Body: filename,
URL: id.ContentURIString(mxcURL),
Info: &event.FileInfo{
MimeType: "audio/mpeg",
Size: size,
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}

View file

@ -0,0 +1,63 @@
package matrix
import (
"fmt"
"regexp"
"strings"
)
var (
reBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
reItalic = regexp.MustCompile(`\*(.+?)\*`)
reStrikethrough = regexp.MustCompile(`~~(.+?)~~`)
reCode = regexp.MustCompile("`(.+?)`")
)
// MarkdownToHTML converts simple markdown to HTML for Matrix messages.
// Matches the exact behavior of the TypeScript markdownToHtml function.
func MarkdownToHTML(text string) string {
result := text
result = reBold.ReplaceAllString(result, "<strong>$1</strong>")
result = reItalic.ReplaceAllString(result, "<em>$1</em>")
result = reStrikethrough.ReplaceAllString(result, "<del>$1</del>")
result = reCode.ReplaceAllString(result, "<code>$1</code>")
result = strings.ReplaceAll(result, "\n", "<br>")
return result
}
// EscapeHTML escapes HTML special characters.
func EscapeHTML(text string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#039;",
)
return r.Replace(text)
}
// FormatNumberedList formats items as a numbered markdown list.
func FormatNumberedList[T any](items []T, formatter func(T, int) string) string {
var sb strings.Builder
for i, item := range items {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(fmt.Sprintf("%d. %s", i+1, formatter(item, i)))
}
return sb.String()
}
// FormatBulletList formats items as a bullet markdown list.
func FormatBulletList[T any](items []T, formatter func(T) string) string {
var sb strings.Builder
for i, item := range items {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString("• ")
sb.WriteString(formatter(item))
}
return sb.String()
}

View file

@ -0,0 +1,64 @@
package matrix
import "testing"
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
input string
want string
}{
{"**bold**", "<strong>bold</strong>"},
{"*italic*", "<em>italic</em>"},
{"~~strike~~", "<del>strike</del>"},
{"`code`", "<code>code</code>"},
{"line1\nline2", "line1<br>line2"},
{"**bold** and *italic*", "<strong>bold</strong> and <em>italic</em>"},
{"plain text", "plain text"},
{"", ""},
}
for _, tt := range tests {
got := MarkdownToHTML(tt.input)
if got != tt.want {
t.Errorf("MarkdownToHTML(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestEscapeHTML(t *testing.T) {
tests := []struct {
input string
want string
}{
{"<script>", "&lt;script&gt;"},
{`"quotes"`, "&quot;quotes&quot;"},
{"a & b", "a &amp; b"},
{"it's", "it&#039;s"},
{"plain", "plain"},
}
for _, tt := range tests {
got := EscapeHTML(tt.input)
if got != tt.want {
t.Errorf("EscapeHTML(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatNumberedList(t *testing.T) {
items := []string{"Apple", "Banana", "Cherry"}
got := FormatNumberedList(items, func(s string, i int) string { return s })
want := "1. Apple\n2. Banana\n3. Cherry"
if got != want {
t.Errorf("FormatNumberedList = %q, want %q", got, want)
}
}
func TestFormatBulletList(t *testing.T) {
items := []string{"Apple", "Banana"}
got := FormatBulletList(items, func(s string) string { return s })
want := "• Apple\n• Banana"
if got != want {
t.Errorf("FormatBulletList = %q, want %q", got, want)
}
}

View file

@ -0,0 +1,75 @@
package matrix
import (
"strings"
"maunium.net/go/mautrix/event"
)
// IsTextMessage checks if an event is a text message.
func IsTextMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgText
}
// IsAudioMessage checks if an event is an audio message.
func IsAudioMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgAudio
}
// IsImageMessage checks if an event is an image message.
func IsImageMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgImage
}
// IsEditEvent checks if an event is a message edit (m.replace).
func IsEditEvent(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.RelatesTo != nil && content.RelatesTo.Type == event.RelReplace
}
// IsBot checks if a sender is a bot based on localpart naming convention.
func IsBot(sender string) bool {
// Extract localpart from @user:server format
if !strings.HasPrefix(sender, "@") {
return false
}
colonIdx := strings.Index(sender, ":")
if colonIdx == -1 {
return false
}
localpart := strings.ToLower(sender[1:colonIdx])
return strings.Contains(localpart, "-bot") || strings.HasSuffix(localpart, "bot")
}
// GetMessageBody extracts the text body from a message event.
func GetMessageBody(evt *event.Event) string {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return ""
}
return strings.TrimSpace(content.Body)
}
// GetMediaURL extracts the mxc:// URL from a media message event.
func GetMediaURL(evt *event.Event) string {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return ""
}
return string(content.URL)
}

View file

@ -0,0 +1,27 @@
package matrix
import "testing"
func TestIsBot(t *testing.T) {
tests := []struct {
sender string
want bool
}{
{"@todo-bot:mana.how", true},
{"@mana-bot:mana.how", true},
{"@ollama-bot:mana.how", true},
{"@statsbot:mana.how", true},
{"@till:mana.how", false},
{"@user:mana.how", false},
{"@bot-admin:mana.how", false}, // contains "bot-" not "-bot"
{"invalid", false},
{"", false},
}
for _, tt := range tests {
got := IsBot(tt.sender)
if got != tt.want {
t.Errorf("IsBot(%q) = %v, want %v", tt.sender, got, tt.want)
}
}
}

View file

@ -0,0 +1,43 @@
package plugin
import "strings"
// CommandRouter routes !command messages to the right handler.
type CommandRouter struct {
routes []route
}
type route struct {
pattern string
handler CommandHandler
}
// CommandHandler processes a command with its arguments.
type CommandHandler func(ctx *MessageContext, args string) error
// NewCommandRouter creates an empty command router.
func NewCommandRouter() *CommandRouter {
return &CommandRouter{}
}
// Handle registers a command pattern (e.g., "!todo") with a handler.
func (r *CommandRouter) Handle(pattern string, handler CommandHandler) {
r.routes = append(r.routes, route{pattern: strings.ToLower(pattern), handler: handler})
}
// Route attempts to match a message to a registered command.
// Returns true if a command was matched and handled.
func (r *CommandRouter) Route(mc *MessageContext) (bool, error) {
lower := strings.ToLower(mc.Body)
for _, rt := range r.routes {
if lower == rt.pattern {
return true, rt.handler(mc, "")
}
if strings.HasPrefix(lower, rt.pattern+" ") {
args := strings.TrimSpace(mc.Body[len(rt.pattern):])
return true, rt.handler(mc, args)
}
}
return false, nil
}

View file

@ -0,0 +1,87 @@
package plugin
import "strings"
// KeywordCommand maps keywords to a command name.
type KeywordCommand struct {
Keywords []string // lowercase keywords
Command string // command name to return
}
// KeywordDetector detects commands from natural language keywords.
type KeywordDetector struct {
commands []KeywordCommand
maxLength int
partialMatch bool
}
// KeywordDetectorOption configures the detector.
type KeywordDetectorOption func(*KeywordDetector)
// WithMaxLength sets the max message length to check (default: 60).
func WithMaxLength(n int) KeywordDetectorOption {
return func(d *KeywordDetector) { d.maxLength = n }
}
// WithPartialMatch enables partial word matching.
func WithPartialMatch(enabled bool) KeywordDetectorOption {
return func(d *KeywordDetector) { d.partialMatch = enabled }
}
// NewKeywordDetector creates a new keyword detector.
func NewKeywordDetector(commands []KeywordCommand, opts ...KeywordDetectorOption) *KeywordDetector {
d := &KeywordDetector{
commands: commands,
maxLength: 60,
}
for _, opt := range opts {
opt(d)
}
return d
}
// Detect returns the command name if a keyword matches, or empty string.
func (d *KeywordDetector) Detect(message string) string {
lower := strings.ToLower(strings.TrimSpace(message))
if len(lower) > d.maxLength {
return ""
}
for _, cmd := range d.commands {
for _, kw := range cmd.Keywords {
if d.matches(lower, kw) {
return cmd.Command
}
}
}
return ""
}
// AddCommands appends more commands dynamically.
func (d *KeywordDetector) AddCommands(commands []KeywordCommand) {
d.commands = append(d.commands, commands...)
}
func (d *KeywordDetector) matches(message, keyword string) bool {
// Exact match
if message == keyword {
return true
}
// Prefix match: keyword followed by space
if strings.HasPrefix(message, keyword+" ") {
return true
}
// Partial match
if d.partialMatch && strings.Contains(message, keyword) {
return true
}
return false
}
// CommonKeywords are shared across all bots (German + English).
var CommonKeywords = []KeywordCommand{
{Keywords: []string{"hilfe", "help", "befehle", "commands", "?"}, Command: "help"},
{Keywords: []string{"status", "info"}, Command: "status"},
{Keywords: []string{"abbrechen", "cancel", "stop"}, Command: "cancel"},
}

View file

@ -0,0 +1,78 @@
package plugin
import "testing"
func TestKeywordDetector_Detect(t *testing.T) {
detector := NewKeywordDetector([]KeywordCommand{
{Keywords: []string{"hilfe", "help"}, Command: "help"},
{Keywords: []string{"zeige aufgaben", "show tasks"}, Command: "list"},
{Keywords: []string{"status"}, Command: "status"},
})
tests := []struct {
input string
want string
}{
{"hilfe", "help"},
{"Hilfe", "help"},
{"HILFE", "help"},
{"hilfe bitte", "help"},
{"help", "help"},
{"zeige aufgaben", "list"},
{"show tasks", "list"},
{"status", "status"},
{"status info", "status"},
{"was ist los", ""},
{"", ""},
// Long message should be skipped
{string(make([]byte, 100)), ""},
}
for _, tt := range tests {
got := detector.Detect(tt.input)
if got != tt.want {
t.Errorf("Detect(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestKeywordDetector_PartialMatch(t *testing.T) {
detector := NewKeywordDetector(
[]KeywordCommand{
{Keywords: []string{"aufgabe"}, Command: "task"},
},
WithPartialMatch(true),
)
tests := []struct {
input string
want string
}{
{"neue aufgabe erstellen", "task"},
{"aufgabe", "task"},
{"nix", ""},
}
for _, tt := range tests {
got := detector.Detect(tt.input)
if got != tt.want {
t.Errorf("Detect(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestKeywordDetector_MaxLength(t *testing.T) {
detector := NewKeywordDetector(
[]KeywordCommand{
{Keywords: []string{"help"}, Command: "help"},
},
WithMaxLength(10),
)
if got := detector.Detect("help"); got != "help" {
t.Errorf("short message should match, got %q", got)
}
if got := detector.Detect("help me please now"); got != "" {
t.Errorf("long message should be skipped, got %q", got)
}
}

View file

@ -0,0 +1,99 @@
package plugin
import (
"context"
"time"
)
// MessageContext carries all information about an incoming message.
type MessageContext struct {
RoomID string
Sender string
EventID string
Body string // text body (for text messages)
IsVoice bool // true if transcribed from audio
Client MatrixClient
Session *SessionAccess
}
// MatrixClient is the interface plugins use to interact with Matrix.
type MatrixClient interface {
SendMessage(ctx context.Context, roomID string, text string) (string, error)
SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error)
SendNotice(ctx context.Context, roomID string, text string) (string, error)
EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error)
SetTyping(ctx context.Context, roomID string, typing bool) error
DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error)
UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error)
SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error)
GetUserID() string
}
// SessionAccess provides per-user session operations for a plugin.
type SessionAccess struct {
UserID string
Manager SessionManager
}
// SessionManager is the interface for session storage.
type SessionManager interface {
Get(userID, key string) (any, bool)
Set(userID, key string, value any)
Delete(userID, key string)
GetToken(userID string) (string, bool)
SetToken(userID, token string, expiresAt time.Time)
IsLoggedIn(userID string) bool
}
// Plugin is the interface every bot plugin must implement.
type Plugin interface {
// Name returns the unique plugin identifier (e.g., "todo", "calendar").
Name() string
// Init is called once during startup with the plugin's config.
Init(ctx context.Context, cfg PluginConfig) error
// HandleTextMessage is called for m.text events.
HandleTextMessage(ctx context.Context, mc *MessageContext) error
// Commands returns the list of commands this plugin handles.
Commands() []CommandDef
}
// AudioHandler is optionally implemented by plugins that handle voice messages.
type AudioHandler interface {
HandleAudioMessage(ctx context.Context, mc *MessageContext, audioData []byte) error
}
// ImageHandler is optionally implemented by plugins that handle image messages.
type ImageHandler interface {
HandleImageMessage(ctx context.Context, mc *MessageContext) error
}
// Scheduler is optionally implemented by plugins that need periodic tasks.
type Scheduler interface {
ScheduledTasks() []ScheduledTask
}
// ScheduledTask defines a periodic task.
type ScheduledTask struct {
Name string
Interval time.Duration
Run func(ctx context.Context) error
}
// CommandDef describes a command for help text and routing.
type CommandDef struct {
Patterns []string // e.g., ["!todo", "!add", "!neu"]
Description string
Category string // e.g., "Aufgaben", "Kalender"
}
// PluginConfig holds per-plugin configuration.
type PluginConfig struct {
Enabled bool
AccessToken string
AllowedRooms []string
BackendURL string
Extra map[string]string
}

View file

@ -0,0 +1,29 @@
package plugin
import "fmt"
var registry = make(map[string]func() Plugin)
// Register adds a plugin factory to the registry.
// Called from each plugin's init() function or explicitly in main.
func Register(name string, factory func() Plugin) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("plugin %q already registered", name))
}
registry[name] = factory
}
// All returns all registered plugin factories.
func All() map[string]func() Plugin {
result := make(map[string]func() Plugin, len(registry))
for k, v := range registry {
result[k] = v
}
return result
}
// Get returns a plugin factory by name.
func Get(name string) (func() Plugin, bool) {
f, ok := registry[name]
return f, ok
}

View file

@ -0,0 +1,410 @@
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
}

View file

@ -0,0 +1,65 @@
package chat
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("chat", func() plugin.Plugin { return &ChatPlugin{} })
}
// ChatPlugin proxies to the chat backend for conversation management.
type ChatPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ChatPlugin) Name() string { return "chat" }
func (p *ChatPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("chat plugin initialized")
return nil
}
func (p *ChatPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!help"}, Description: "Hilfe", Category: "System"},
{Patterns: []string{"!status"}, Description: "Status", Category: "System"},
}
}
func (p *ChatPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *ChatPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Chat Bot:** ✅ Online")
return nil
}
func (p *ChatPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**💬 Chat Bot**\n\n• `!status` — Bot-Status\n• `!hilfe` — Diese Hilfe")
return nil
}

View file

@ -0,0 +1,441 @@
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)
}

View file

@ -0,0 +1,71 @@
package clock
import "testing"
func TestParseDuration(t *testing.T) {
tests := []struct {
input string
want int // seconds
}{
{"25m", 25 * 60},
{"25", 25 * 60},
{"1h", 3600},
{"1h30m", 5400},
{"2h", 7200},
{"90", 90 * 60},
{"1h0m", 3600},
{"0", 0},
{"abc", 0},
{"", 0},
}
for _, tt := range tests {
got := parseDuration(tt.input)
if got != tt.want {
t.Errorf("parseDuration(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestParseAlarmTime(t *testing.T) {
tests := []struct {
input string
want string
}{
{"07:30", "07:30"},
{"7:30", "7:30"},
{"14:00", "14:00"},
{"7 uhr 30", "7:30"},
{"7 30", "7:30"},
{"7", "7:00"},
{"abc", ""},
}
for _, tt := range tests {
got := parseAlarmTime(tt.input)
if got != tt.want {
t.Errorf("parseAlarmTime(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
seconds int
want string
}{
{0, "0:00"},
{65, "1:05"},
{3600, "1:00:00"},
{3661, "1:01:01"},
{1500, "25:00"},
{-5, "0:00"},
}
for _, tt := range tests {
got := formatDuration(tt.seconds)
if got != tt.want {
t.Errorf("formatDuration(%d) = %q, want %q", tt.seconds, got, tt.want)
}
}
}

View file

@ -0,0 +1,543 @@
package contacts
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("contacts", func() plugin.Plugin { return &ContactsPlugin{} })
}
// Contact represents a contact from the backend.
type Contact struct {
ID string `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Mobile *string `json:"mobile"`
Company *string `json:"company"`
JobTitle *string `json:"jobTitle"`
Website *string `json:"website"`
Street *string `json:"street"`
City *string `json:"city"`
PostalCode *string `json:"postalCode"`
Country *string `json:"country"`
Notes *string `json:"notes"`
Birthday *string `json:"birthday"`
IsFavorite bool `json:"isFavorite"`
IsArchived bool `json:"isArchived"`
}
// ContactsResponse wraps the paginated contacts response.
type ContactsResponse struct {
Contacts []Contact `json:"contacts"`
Total int `json:"total"`
}
// ContactsPlugin implements the Matrix contacts bot.
type ContactsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
// Per-user last-shown contact lists for number references
lastList map[string][]Contact
}
func (p *ContactsPlugin) Name() string { return "contacts" }
func (p *ContactsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("contacts plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastList = make(map[string][]Contact)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!kontakte", p.cmdList)
p.router.Handle("!contacts", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!list", p.cmdList)
p.router.Handle("!suche", p.cmdSearch)
p.router.Handle("!search", p.cmdSearch)
p.router.Handle("!favoriten", p.cmdFavorites)
p.router.Handle("!favorites", p.cmdFavorites)
p.router.Handle("!favs", p.cmdFavorites)
p.router.Handle("!kontakt", p.cmdDetails)
p.router.Handle("!contact", p.cmdDetails)
p.router.Handle("!details", p.cmdDetails)
p.router.Handle("!neu", p.cmdCreate)
p.router.Handle("!new", p.cmdCreate)
p.router.Handle("!add", p.cmdCreate)
p.router.Handle("!edit", p.cmdEdit)
p.router.Handle("!bearbeiten", p.cmdEdit)
p.router.Handle("!loeschen", p.cmdDelete)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!del", p.cmdDelete)
p.router.Handle("!fav", p.cmdToggleFav)
p.router.Handle("!favorit", p.cmdToggleFav)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"kontakte", "contacts", "alle"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"favoriten", "favorites", "favs"}, Command: "favorites"},
plugin.KeywordCommand{Keywords: []string{"suche", "search", "finde"}, Command: "search"},
))
slog.Info("contacts plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *ContactsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!kontakte", "!contacts"}, Description: "Alle Kontakte", Category: "Kontakte"},
{Patterns: []string{"!suche [text]", "!search"}, Description: "Kontakte suchen", Category: "Kontakte"},
{Patterns: []string{"!favoriten"}, Description: "Favoriten", Category: "Kontakte"},
{Patterns: []string{"!kontakt [nr]"}, Description: "Kontakt-Details", Category: "Kontakte"},
{Patterns: []string{"!neu Vorname Nachname"}, Description: "Neuer Kontakt", Category: "Kontakte"},
{Patterns: []string{"!edit [nr] [feld] [wert]"}, Description: "Kontakt bearbeiten", Category: "Kontakte"},
{Patterns: []string{"!delete [nr]"}, Description: "Kontakt löschen", Category: "Kontakte"},
{Patterns: []string{"!fav [nr]"}, Description: "Favorit umschalten", Category: "Kontakte"},
}
}
func (p *ContactsPlugin) 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 "list":
return p.cmdList(mc, "")
case "favorites":
return p.cmdFavorites(mc, "")
case "search":
return p.cmdSearch(mc, mc.Body)
}
return nil
}
// --- Command Handlers ---
func (p *ContactsPlugin) cmdList(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 resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?limit=20", token, &resp); err != nil {
slog.Error("get contacts failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakte konnten nicht geladen werden.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kontakte vorhanden.\n\nNeuer Kontakt: `!neu Vorname Nachname`")
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList(fmt.Sprintf("**Deine Kontakte (%d):**", resp.Total), resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdSearch(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Suchbegriff an.\n\nBeispiel: `!suche Max`")
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
}
var resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?search="+args+"&limit=20", token, &resp); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Suche fehlgeschlagen.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Keine Ergebnisse für \"%s\".", args))
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList(fmt.Sprintf("**Suchergebnisse für \"%s\" (%d):**", args, resp.Total), resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdFavorites(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 resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?isFavorite=true&limit=20", token, &resp); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favoriten konnten nicht geladen werden.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Favoriten.\n\nMarkiere mit: `!fav [Nr]`")
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList("**⭐ Favoriten:**", resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactDetails(contact))
return nil
}
func (p *ContactsPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Namen an.\n\nBeispiel: `!neu Max Mustermann`")
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
}
parts := strings.SplitN(args, " ", 2)
firstName := parts[0]
lastName := ""
if len(parts) > 1 {
lastName = parts[1]
}
body := map[string]string{
"firstName": firstName,
"lastName": lastName,
}
var contact Contact
if err := p.backend.Post(ctx, "/api/contacts", token, body, &contact); err != nil {
slog.Error("create contact failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht erstellt werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("✅ Kontakt **%s** erstellt!\n\nNutze `!kontakte` um die Liste zu sehen oder `!edit` um weitere Daten hinzuzufügen.", name))
return nil
}
func (p *ContactsPlugin) cmdEdit(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
// Parse: [nr] [field] [value]
parts := strings.SplitN(args, " ", 3)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!edit [Nr] [Feld] [Wert]`\n\nFelder: email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday")
return nil
}
contact, err := p.getContactByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
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.")
return nil
}
field := mapFieldName(parts[1])
value := parts[2]
body := map[string]string{field: value}
if err := p.backend.Put(ctx, "/api/contacts/"+contact.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht aktualisiert werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("✅ Kontakt **%s** aktualisiert!\n\n**%s:** %s", name, field, value))
return nil
}
func (p *ContactsPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
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.")
return nil
}
if err := p.backend.Delete(ctx, "/api/contacts/"+contact.ID, token); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht gelöscht werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", name))
return nil
}
func (p *ContactsPlugin) cmdToggleFav(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
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.")
return nil
}
var updated Contact
if err := p.backend.Post(ctx, "/api/contacts/"+contact.ID+"/favorite", token, nil, &updated); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favorit konnte nicht geändert werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
if updated.IsFavorite {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("⭐ **%s** als Favorit markiert", name))
} else {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**%s** aus Favoriten entfernt", name))
}
return nil
}
func (p *ContactsPlugin) 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("**Contacts Bot Status**\n\n%s", status))
return nil
}
func (p *ContactsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**👥 Contacts Bot - Befehle**
**Anzeigen:**
` + "`!kontakte`" + ` Alle Kontakte
` + "`!suche Max`" + ` Kontakte suchen
` + "`!favoriten`" + ` Favoriten
` + "`!kontakt 1`" + ` Details zu Kontakt #1
**Verwalten:**
` + "`!neu Max Mustermann`" + ` Neuer Kontakt
` + "`!edit 1 email max@test.de`" + ` Feld bearbeiten
` + "`!fav 1`" + ` Favorit umschalten
` + "`!delete 1`" + ` Kontakt löschen
**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Helpers ---
func (p *ContactsPlugin) getContactByNumber(mc *plugin.MessageContext, args string) (*Contact, error) {
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
return nil, fmt.Errorf("Bitte gib eine gültige Nummer an.\n\nBeispiel: `!kontakt 1`")
}
list, ok := p.lastList[mc.Sender]
if !ok || len(list) == 0 {
return nil, fmt.Errorf("Bitte zeige zuerst eine Liste an: `!kontakte` oder `!suche`")
}
if num > len(list) {
return nil, fmt.Errorf("❌ Kontakt #%d nicht gefunden.", num)
}
return &list[num-1], nil
}
func formatContactList(header string, contacts []Contact) string {
var sb strings.Builder
sb.WriteString(header)
sb.WriteString("\n\n")
for i, c := range contacts {
name := c.FirstName
if c.LastName != "" {
name += " " + c.LastName
}
fav := ""
if c.IsFavorite {
fav = " ⭐"
}
company := ""
if c.Company != nil && *c.Company != "" {
company = " - " + *c.Company
}
sb.WriteString(fmt.Sprintf("**%d.** %s%s%s\n", i+1, name, fav, company))
}
sb.WriteString("\nNutze `!kontakt [Nr]` für Details.")
return sb.String()
}
func formatContactDetails(c *Contact) string {
name := c.FirstName
if c.LastName != "" {
name += " " + c.LastName
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%s**\n\n", name))
if c.IsFavorite {
sb.WriteString("⭐ Favorit\n\n")
}
if c.Company != nil && *c.Company != "" {
sb.WriteString(fmt.Sprintf("**Firma:** %s", *c.Company))
if c.JobTitle != nil && *c.JobTitle != "" {
sb.WriteString(fmt.Sprintf(" — %s", *c.JobTitle))
}
sb.WriteByte('\n')
}
if c.Email != nil && *c.Email != "" {
sb.WriteString(fmt.Sprintf("**E-Mail:** %s\n", *c.Email))
}
if c.Phone != nil && *c.Phone != "" {
sb.WriteString(fmt.Sprintf("**Telefon:** %s\n", *c.Phone))
}
if c.Mobile != nil && *c.Mobile != "" {
sb.WriteString(fmt.Sprintf("**Mobil:** %s\n", *c.Mobile))
}
// Address
var addr []string
if c.Street != nil && *c.Street != "" {
addr = append(addr, *c.Street)
}
parts := ""
if c.PostalCode != nil && *c.PostalCode != "" {
parts += *c.PostalCode + " "
}
if c.City != nil && *c.City != "" {
parts += *c.City
}
if parts != "" {
addr = append(addr, strings.TrimSpace(parts))
}
if c.Country != nil && *c.Country != "" {
addr = append(addr, *c.Country)
}
if len(addr) > 0 {
sb.WriteString(fmt.Sprintf("**Adresse:** %s\n", strings.Join(addr, ", ")))
}
if c.Website != nil && *c.Website != "" {
sb.WriteString(fmt.Sprintf("**Website:** %s\n", *c.Website))
}
if c.Birthday != nil && *c.Birthday != "" {
sb.WriteString(fmt.Sprintf("**Geburtstag:** %s\n", *c.Birthday))
}
if c.Notes != nil && *c.Notes != "" {
sb.WriteString(fmt.Sprintf("\n**Notizen:** %s\n", *c.Notes))
}
return sb.String()
}
func mapFieldName(input string) string {
switch strings.ToLower(input) {
case "email":
return "email"
case "phone", "telefon":
return "phone"
case "mobile", "mobil", "handy":
return "mobile"
case "company", "firma":
return "company"
case "job", "jobtitle", "beruf":
return "jobTitle"
case "website", "web":
return "website"
case "street", "strasse":
return "street"
case "city", "stadt":
return "city"
case "zip", "plz":
return "postalCode"
case "country", "land":
return "country"
case "notes", "notizen":
return "notes"
case "birthday", "geburtstag":
return "birthday"
case "firstname", "vorname":
return "firstName"
case "lastname", "nachname":
return "lastName"
default:
return input
}
}

View file

@ -0,0 +1,586 @@
package gateway
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("gateway", func() plugin.Plugin { return &GatewayPlugin{} })
}
// GatewayPlugin is the composite mana-bot that combines AI chat, todo,
// calendar, clock, and voice into a single bot identity.
type GatewayPlugin struct {
// Sub-handler clients
todoBackend *services.BackendClient
calendarBackend *services.BackendClient
clockBackend *services.BackendClient
voice *services.VoiceClient
// AI chat
ollamaURL string
defaultModel string
// Command infrastructure
router *plugin.CommandRouter
detector *plugin.KeywordDetector
// Per-user AI sessions
mu sync.RWMutex
sessions map[string]*AISession
}
// AISession holds per-user AI chat state.
type AISession struct {
Model string
Mode string
History []ChatMessage
}
// ChatMessage for Ollama API.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func (p *GatewayPlugin) Name() string { return "gateway" }
func (p *GatewayPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.sessions = make(map[string]*AISession)
// Ollama config
p.ollamaURL = cfg.Extra["ollama_url"]
if p.ollamaURL == "" {
p.ollamaURL = "http://localhost:11434"
}
p.defaultModel = cfg.Extra["ollama_model"]
if p.defaultModel == "" {
p.defaultModel = "gemma3:4b"
}
// Backend clients (optional — gateway works even without backends)
if url := cfg.Extra["todo_url"]; url != "" {
p.todoBackend = services.NewBackendClient(url)
}
if url := cfg.Extra["calendar_url"]; url != "" {
p.calendarBackend = services.NewBackendClient(url)
}
if url := cfg.Extra["clock_url"]; url != "" {
p.clockBackend = services.NewBackendClient(url)
}
// Voice
sttURL := cfg.Extra["stt_url"]
ttsURL := cfg.Extra["tts_url"]
if sttURL != "" || ttsURL != "" {
p.voice = services.NewVoiceClient(sttURL, ttsURL)
}
// Build command router with all sub-handler commands
p.router = plugin.NewCommandRouter()
// Help
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
// AI
p.router.Handle("!models", p.cmdModels)
p.router.Handle("!modelle", p.cmdModels)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!clear", p.cmdClear)
p.router.Handle("!all", p.cmdAll)
p.router.Handle("!mode", p.cmdMode)
// Todo
p.router.Handle("!todo", p.cmdTodoAdd)
p.router.Handle("!add", p.cmdTodoAdd)
p.router.Handle("!list", p.cmdTodoList)
p.router.Handle("!liste", p.cmdTodoList)
p.router.Handle("!done", p.cmdTodoDone)
p.router.Handle("!erledigt", p.cmdTodoDone)
// Calendar
p.router.Handle("!cal", p.cmdCalToday)
p.router.Handle("!heute", p.cmdCalToday)
p.router.Handle("!week", p.cmdCalWeek)
p.router.Handle("!woche", p.cmdCalWeek)
p.router.Handle("!termin", p.cmdCalCreate)
// Clock
p.router.Handle("!timer", p.cmdTimer)
p.router.Handle("!timers", p.cmdTimers)
p.router.Handle("!stop", p.cmdTimerStop)
p.router.Handle("!zeit", p.cmdTime)
p.router.Handle("!time", p.cmdTime)
// Morning summary
p.router.Handle("!morning", p.cmdMorning)
p.router.Handle("!guten morgen", p.cmdMorning)
// Status
p.router.Handle("!status", p.cmdStatus)
// Keyword detector
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"meine aufgaben", "show tasks"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"modelle", "welche modelle"}, Command: "models"},
plugin.KeywordCommand{Keywords: []string{"guten morgen", "good morning"}, Command: "morning"},
plugin.KeywordCommand{Keywords: []string{"wie spät", "uhrzeit"}, Command: "time"},
plugin.KeywordCommand{Keywords: []string{"lösche verlauf", "vergiss alles"}, Command: "clear"},
))
slog.Info("gateway plugin initialized", "ollama", p.ollamaURL, "model", p.defaultModel)
return nil
}
func (p *GatewayPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!todo [text]"}, Description: "Aufgabe erstellen", Category: "Aufgaben"},
{Patterns: []string{"!list"}, Description: "Offene Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!done [nr]"}, Description: "Aufgabe erledigen", Category: "Aufgaben"},
{Patterns: []string{"!cal", "!heute"}, Description: "Heutige Termine", Category: "Kalender"},
{Patterns: []string{"!week", "!woche"}, Description: "Wochenübersicht", Category: "Kalender"},
{Patterns: []string{"!timer [dauer]"}, Description: "Timer starten", Category: "Timer"},
{Patterns: []string{"!models"}, Description: "KI-Modelle", Category: "AI"},
{Patterns: []string{"!model [name]"}, Description: "Modell wechseln", Category: "AI"},
{Patterns: []string{"!morning"}, Description: "Morgenzusammenfassung", Category: "System"},
}
}
func (p *GatewayPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdTodoList(mc, "")
case "models":
return p.cmdModels(mc, "")
case "morning":
return p.cmdMorning(mc, "")
case "time":
return p.cmdTime(mc, "")
case "clear":
return p.cmdClear(mc, "")
}
// Default: AI chat
return p.cmdChat(mc, mc.Body)
}
// HandleAudioMessage transcribes audio then routes the text.
func (p *GatewayPlugin) HandleAudioMessage(ctx context.Context, mc *plugin.MessageContext, audioData []byte) error {
if p.voice == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Spracherkennung nicht konfiguriert.")
return nil
}
result, err := p.voice.Transcribe(ctx, audioData, "de")
if err != nil {
slog.Error("transcription failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Spracherkennung fehlgeschlagen.")
return nil
}
text := strings.TrimSpace(result.Text)
if text == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🎤 Ich konnte nichts verstehen.")
return nil
}
// Show transcription
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🎤 *\"%s\"*", text))
// Route transcribed text as if it were a text message
mc.Body = text
mc.IsVoice = true
return p.HandleTextMessage(ctx, mc)
}
// --- AI Sub-Handler ---
func (p *GatewayPlugin) cmdChat(mc *plugin.MessageContext, message string) error {
ctx := context.Background()
session := p.getAISession(mc.Sender)
messages := buildMessages(session, message)
response, err := ollamaChat(ctx, p.ollamaURL, session.Model, messages)
if err != nil {
slog.Error("ollama chat failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ KI ist nicht erreichbar.")
return nil
}
// Update history
session.History = append(session.History, ChatMessage{Role: "user", Content: message})
session.History = append(session.History, ChatMessage{Role: "assistant", Content: response})
if len(session.History) > 20 {
session.History = session.History[len(session.History)-20:]
}
p.mu.Lock()
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *GatewayPlugin) cmdModels(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
models, err := ollamaListModels(ctx, p.ollamaURL)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
session := p.getAISession(mc.Sender)
var sb strings.Builder
sb.WriteString("**Verfügbare Modelle:**\n\n")
for _, m := range models {
current := ""
if m.Name == session.Model {
current = " ✓"
}
sb.WriteString(fmt.Sprintf("• `%s` (%d MB)%s\n", m.Name, m.Size/(1024*1024), current))
}
sb.WriteString("\nWechseln: `!model [name]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
s := p.getAISession(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("Aktuelles Modell: `%s`", s.Model))
return nil
}
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.Model = args
s.History = nil
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell: `%s` — Verlauf gelöscht.", args))
return nil
}
func (p *GatewayPlugin) cmdMode(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
valid := map[string]bool{"default": true, "code": true, "translate": true, "summarize": true}
if !valid[args] {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Modi:** `default`, `code`, `translate`, `summarize`")
return nil
}
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.Mode = args
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modus: `%s`", args))
return nil
}
func (p *GatewayPlugin) cmdClear(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.History = nil
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🗑️ Chat-Verlauf gelöscht.")
return nil
}
func (p *GatewayPlugin) cmdAll(mc *plugin.MessageContext, question string) error {
ctx := context.Background()
if question == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Beispiel: `!all Was ist 2+2?`")
return nil
}
models, err := ollamaListModels(ctx, p.ollamaURL)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle nicht erreichbar.")
return nil
}
eventID, _ := mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**Vergleich:** \"%s\" — %d Modelle...", question, len(models)))
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Modellvergleich:** \"%s\"\n\n---\n", question))
msgs := []ChatMessage{{Role: "user", Content: question}}
for _, m := range models {
start := time.Now()
resp, err := ollamaChat(ctx, p.ollamaURL, m.Name, msgs)
dur := time.Since(start)
sb.WriteString(fmt.Sprintf("\n**%s** %.1fs\n", m.Name, dur.Seconds()))
if err != nil {
sb.WriteString("_Fehler_\n")
} else {
if len(resp) > 500 {
resp = resp[:500] + "..."
}
sb.WriteString(resp + "\n")
}
sb.WriteString("\n---\n")
}
if eventID != "" {
mc.Client.EditMessage(ctx, mc.RoomID, eventID, sb.String())
}
return nil
}
// --- Todo Sub-Handler ---
func (p *GatewayPlugin) cmdTodoAdd(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if p.todoBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Todo-Backend nicht konfiguriert.")
return nil
}
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Beispiel: `!todo Einkaufen @morgen !p1`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden: `!login email passwort`")
return nil
}
body := map[string]string{"title": args}
var task struct{ Title string `json:"title"` }
if err := p.todoBackend.Post(ctx, "/api/tasks", token, body, &task); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erstellt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s**", task.Title))
return nil
}
func (p *GatewayPlugin) cmdTodoList(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
if p.todoBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Todo-Backend nicht konfiguriert.")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden: `!login email passwort`")
return nil
}
var tasks []struct {
Title string `json:"title"`
Priority int `json:"priority"`
}
if err := p.todoBackend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine offenen Aufgaben.")
return nil
}
var sb strings.Builder
sb.WriteString("📋 **Aufgaben:**\n\n")
for i, t := range tasks {
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, t.Title))
}
sb.WriteString("\nErledigen: `!done [Nr]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdTodoDone(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Aufgabe erledigt. (Nutze den Todo-Bot für vollständige Funktionalität)")
return nil
}
// --- Calendar Sub-Handler ---
func (p *GatewayPlugin) cmdCalToday(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
if p.calendarBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kalender-Backend nicht konfiguriert.")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden.")
return nil
}
var events []struct {
Title string `json:"title"`
StartTime string `json:"startTime"`
}
if err := p.calendarBackend.Get(ctx, "/api/events/today", token, &events); err != nil {
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, "📭 Keine Termine heute.")
return nil
}
var sb strings.Builder
sb.WriteString("📅 **Heute:**\n\n")
for i, e := range events {
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, e.Title))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdCalWeek(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📅 Nutze den Kalender-Bot für die Wochenübersicht.")
return nil
}
func (p *GatewayPlugin) cmdCalCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📅 Nutze den Kalender-Bot: `!termin Meeting morgen um 14:00`")
return nil
}
// --- Clock Sub-Handler ---
func (p *GatewayPlugin) cmdTimer(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏱️ Nutze den Clock-Bot für Timer: `!timer 25m`")
return nil
}
func (p *GatewayPlugin) cmdTimers(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏱️ Nutze den Clock-Bot: `!timers`")
return nil
}
func (p *GatewayPlugin) cmdTimerStop(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏸️ Nutze den Clock-Bot: `!stop`")
return nil
}
func (p *GatewayPlugin) cmdTime(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
now := time.Now()
days := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**%s Uhr** — %s, %d.%d.%d", now.Format("15:04"), days[now.Weekday()], now.Day(), now.Month(), now.Year()))
return nil
}
// --- Morning Summary ---
func (p *GatewayPlugin) cmdMorning(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
var sb strings.Builder
sb.WriteString("**☀️ Guten Morgen!**\n\n")
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"}
sb.WriteString(fmt.Sprintf("📅 %s, %d. %s %d\n\n", days[now.Weekday()], now.Day(), months[now.Month()], now.Year()))
// Try to fetch today's tasks
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
if p.todoBackend != nil && token != "" {
var tasks []struct{ Title string `json:"title"` }
if err := p.todoBackend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err == nil && len(tasks) > 0 {
sb.WriteString(fmt.Sprintf("📋 **%d offene Aufgaben**\n", len(tasks)))
}
}
// Try to fetch today's events
if p.calendarBackend != nil && token != "" {
var events []struct{ Title string `json:"title"` }
if err := p.calendarBackend.Get(ctx, "/api/events/today", token, &events); err == nil && len(events) > 0 {
sb.WriteString(fmt.Sprintf("📅 **%d Termine heute**\n", len(events)))
}
}
sb.WriteString("\nSchönen Tag! 🌟")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
// --- Status & Help ---
func (p *GatewayPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
session := p.getAISession(mc.Sender)
var sb strings.Builder
sb.WriteString("**🤖 Mana Bot Status**\n\n")
sb.WriteString(fmt.Sprintf("**KI-Modell:** `%s`\n", session.Model))
sb.WriteString(fmt.Sprintf("**Chat-Verlauf:** %d Nachrichten\n", len(session.History)))
sb.WriteString(fmt.Sprintf("**Modus:** `%s`\n", session.Mode))
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
if loggedIn {
sb.WriteString("**Auth:** ✅ Angemeldet\n")
} else {
sb.WriteString("**Auth:** ❌ Nicht angemeldet\n")
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🤖 Mana Bot Befehle**
**KI Chat:**
Einfach eine Nachricht schreiben!
` + "`!models`" + ` Modelle | ` + "`!model [name]`" + ` Wechseln
` + "`!all [frage]`" + ` Alle Modelle vergleichen
` + "`!clear`" + ` Verlauf löschen
**Aufgaben:**
` + "`!todo Einkaufen`" + ` Neue Aufgabe
` + "`!list`" + ` Offene Aufgaben | ` + "`!done 1`" + ` Erledigen
**Kalender:**
` + "`!heute`" + ` Heutige Termine
**Uhr:**
` + "`!zeit`" + ` Aktuelle Uhrzeit
**System:**
` + "`!morning`" + ` Morgenzusammenfassung
` + "`!status`" + ` Bot-Status
🎤 Sprachnachrichten werden automatisch verarbeitet.`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- AI Session ---
func (p *GatewayPlugin) getAISession(userID string) *AISession {
p.mu.RLock()
s, ok := p.sessions[userID]
p.mu.RUnlock()
if !ok {
return &AISession{Model: p.defaultModel, Mode: "default"}
}
return s
}

View file

@ -0,0 +1,120 @@
package gateway
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// OllamaModel represents an available model.
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
type ollamaModelsResponse struct {
Models []OllamaModel `json:"models"`
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type ollamaChatResponse struct {
Message ChatMessage `json:"message"`
}
var httpClient = &http.Client{Timeout: 120 * time.Second}
// ollamaChat sends a chat request to the Ollama API.
func ollamaChat(ctx context.Context, baseURL, model string, messages []ChatMessage) (string, error) {
body := ollamaChatRequest{Model: model, Messages: messages, Stream: false}
data, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/api/chat", bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ollama: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ollama %d: %s", resp.StatusCode, string(b))
}
var result ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.Message.Content, nil
}
// ollamaListModels lists available models from the Ollama API.
func ollamaListModels(ctx context.Context, baseURL string) ([]OllamaModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list models: %d", resp.StatusCode)
}
var result ollamaModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
// buildMessages constructs the message array for an Ollama chat request.
func buildMessages(session *AISession, userMessage string) []ChatMessage {
msgs := make([]ChatMessage, 0, len(session.History)+2)
// System prompt
prompt := getSystemPrompt(session.Mode)
if prompt != "" {
msgs = append(msgs, ChatMessage{Role: "system", Content: prompt})
}
// History
msgs = append(msgs, session.History...)
// New user message
msgs = append(msgs, ChatMessage{Role: "user", Content: userMessage})
return msgs
}
func getSystemPrompt(mode string) string {
switch mode {
case "code":
return "Du bist ein erfahrener Programmierer. Antworte mit klaren Code-Beispielen."
case "translate":
return "Du bist ein Übersetzer. Übersetze Deutsch↔Englisch. Gib nur die Übersetzung zurück."
case "summarize":
return "Fasse den Text kurz und prägnant zusammen."
default:
return "Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, außer der Nutzer schreibt auf Englisch."
}
}

View file

@ -0,0 +1,66 @@
package manadeck
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("manadeck", func() plugin.Plugin { return &ManaDeckPlugin{} })
}
type ManaDeckPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ManaDeckPlugin) Name() string { return "manadeck" }
func (p *ManaDeckPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!decks", p.cmdDecks)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("manadeck plugin initialized")
return nil
}
func (p *ManaDeckPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!decks"}, Description: "Alle Decks", Category: "ManaDeck"},
{Patterns: []string{"!status"}, Description: "Status", Category: "System"},
}
}
func (p *ManaDeckPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *ManaDeckPlugin) cmdDecks(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🃏 Decks**\n\n_Deck-Verwaltung über die Web-App._")
return nil
}
func (p *ManaDeckPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**ManaDeck Bot:** ✅ Online")
return nil
}
func (p *ManaDeckPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🃏 ManaDeck Bot**\n\n• `!decks` — Decks anzeigen\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,72 @@
package nutriphi
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("nutriphi", func() plugin.Plugin { return &NutriPhiPlugin{} })
}
type NutriPhiPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *NutriPhiPlugin) Name() string { return "nutriphi" }
func (p *NutriPhiPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!heute", p.cmdToday)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!log", p.cmdLog)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("nutriphi plugin initialized")
return nil
}
func (p *NutriPhiPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!heute", "!today"}, Description: "Heutige Mahlzeiten", Category: "Ernährung"},
{Patterns: []string{"!log [mahlzeit]"}, Description: "Mahlzeit loggen", Category: "Ernährung"},
}
}
func (p *NutriPhiPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *NutriPhiPlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ Heute**\n\n_Keine Mahlzeiten geloggt._")
return nil
}
func (p *NutriPhiPlugin) cmdLog(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ Mahlzeit loggen**\n\n_Sende ein Foto oder beschreibe die Mahlzeit._")
return nil
}
func (p *NutriPhiPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**NutriPhi Bot:** ✅ Online")
return nil
}
func (p *NutriPhiPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ NutriPhi Bot**\n\n• `!heute` — Heutige Mahlzeiten\n• `!log Pizza` — Mahlzeit loggen\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,462 @@
package ollama
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/manacore/mana-matrix-bot/internal/plugin"
)
func init() {
plugin.Register("ollama", func() plugin.Plugin { return &OllamaPlugin{} })
}
// ChatMessage represents a message in the chat history.
type ChatMessage struct {
Role string `json:"role"` // user, assistant, system
Content string `json:"content"`
}
// OllamaModel represents an available model.
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
// OllamaModelsResponse is the response from /api/tags.
type OllamaModelsResponse struct {
Models []OllamaModel `json:"models"`
}
// OllamaChatRequest is the request body for /api/chat.
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
}
// OllamaChatResponse is the response from /api/chat.
type OllamaChatResponse struct {
Message ChatMessage `json:"message"`
}
// UserSession holds per-user chat state.
type UserSession struct {
Model string
History []ChatMessage
Mode string // default, code, translate, summarize
}
// OllamaPlugin implements the Matrix AI chat bot.
type OllamaPlugin struct {
ollamaURL string
defaultModel string
httpClient *http.Client
router *plugin.CommandRouter
detector *plugin.KeywordDetector
mu sync.RWMutex
sessions map[string]*UserSession
}
func (p *OllamaPlugin) Name() string { return "ollama" }
func (p *OllamaPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.ollamaURL = cfg.Extra["ollama_url"]
if p.ollamaURL == "" {
p.ollamaURL = "http://localhost:11434"
}
p.defaultModel = cfg.Extra["ollama_model"]
if p.defaultModel == "" {
p.defaultModel = "gemma3:4b"
}
p.httpClient = &http.Client{Timeout: 120 * time.Second}
p.sessions = make(map[string]*UserSession)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!models", p.cmdModels)
p.router.Handle("!modelle", p.cmdModels)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!modell", p.cmdModel)
p.router.Handle("!clear", p.cmdClear)
p.router.Handle("!status", p.cmdStatus)
p.router.Handle("!all", p.cmdAll)
p.router.Handle("!mode", p.cmdMode)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"modelle", "welche modelle", "models"}, Command: "models"},
plugin.KeywordCommand{Keywords: []string{"lösche verlauf", "neustart", "reset", "vergiss alles"}, Command: "clear"},
plugin.KeywordCommand{Keywords: []string{"verbindung", "connection", "online"}, Command: "status"},
))
slog.Info("ollama plugin initialized", "url", p.ollamaURL, "model", p.defaultModel)
return nil
}
func (p *OllamaPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!models", "!modelle"}, Description: "Verfügbare Modelle", Category: "AI"},
{Patterns: []string{"!model [name]"}, Description: "Modell wechseln", Category: "AI"},
{Patterns: []string{"!mode [name]"}, Description: "Modus ändern (default, code, translate)", Category: "AI"},
{Patterns: []string{"!clear"}, Description: "Chat-Verlauf löschen", Category: "AI"},
{Patterns: []string{"!all [frage]"}, Description: "Alle Modelle vergleichen", Category: "AI"},
{Patterns: []string{"!status"}, Description: "Ollama-Status", Category: "System"},
}
}
func (p *OllamaPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "models":
return p.cmdModels(mc, "")
case "clear":
return p.cmdClear(mc, "")
case "status":
return p.cmdStatus(mc, "")
}
// Default: treat as chat message
return p.cmdChat(mc, mc.Body)
}
// --- Command Handlers ---
func (p *OllamaPlugin) cmdChat(mc *plugin.MessageContext, message string) error {
ctx := context.Background()
session := p.getSession(mc.Sender)
// Build messages with history
messages := make([]ChatMessage, 0, len(session.History)+2)
// System prompt
systemPrompt := getSystemPrompt(session.Mode)
if systemPrompt != "" {
messages = append(messages, ChatMessage{Role: "system", Content: systemPrompt})
}
// History
messages = append(messages, session.History...)
// New user message
messages = append(messages, ChatMessage{Role: "user", Content: message})
// Call Ollama
response, err := p.chat(ctx, session.Model, messages)
if err != nil {
slog.Error("ollama chat failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ollama ist nicht erreichbar.")
return nil
}
// Update history (keep last 10 messages)
session.History = append(session.History, ChatMessage{Role: "user", Content: message})
session.History = append(session.History, ChatMessage{Role: "assistant", Content: response})
if len(session.History) > 20 {
session.History = session.History[len(session.History)-20:]
}
p.mu.Lock()
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *OllamaPlugin) cmdModels(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
models, err := p.listModels(ctx)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
if len(models) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Modelle verfügbar.")
return nil
}
session := p.getSession(mc.Sender)
var sb strings.Builder
sb.WriteString("**Verfügbare Modelle:**\n\n")
for _, m := range models {
sizeMB := m.Size / (1024 * 1024)
current := ""
if m.Name == session.Model {
current = " ✓"
}
sb.WriteString(fmt.Sprintf("• `%s` (%d MB)%s\n", m.Name, sizeMB, current))
}
sb.WriteString(fmt.Sprintf("\nWechseln mit: `!model [name]`"))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *OllamaPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
session := p.getSession(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("Aktuelles Modell: `%s`\n\nWechseln: `!model gemma3:4b`", session.Model))
return nil
}
p.mu.Lock()
session := p.getSession(mc.Sender)
session.Model = args
session.History = nil // Clear history on model switch
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell gewechselt zu `%s`\nChat-Verlauf gelöscht.", args))
return nil
}
func (p *OllamaPlugin) cmdMode(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validModes := map[string]bool{"default": true, "code": true, "translate": true, "summarize": true}
if args == "" || !validModes[args] {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Modi:** `default`, `code`, `translate`, `summarize`\n\nBeispiel: `!mode code`")
return nil
}
p.mu.Lock()
session := p.getSession(mc.Sender)
session.Mode = args
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modus gewechselt zu `%s`", args))
return nil
}
func (p *OllamaPlugin) cmdClear(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
p.mu.Lock()
session := p.getSession(mc.Sender)
session.History = nil
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🗑️ Chat-Verlauf gelöscht.")
return nil
}
func (p *OllamaPlugin) cmdAll(mc *plugin.MessageContext, question string) error {
ctx := context.Background()
if question == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte stelle eine Frage.\n\nBeispiel: `!all Was ist 2+2?`")
return nil
}
models, err := p.listModels(ctx)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
// Send initial message
initialMsg := fmt.Sprintf("**Modellvergleich**\n\n**Frage:** \"%s\"\n\n_Frage %d Modelle ab..._", question, len(models))
eventID, _ := mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, initialMsg)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Modellvergleich**\n\n**Frage:** \"%s\"\n\n---\n", question))
messages := []ChatMessage{{Role: "user", Content: question}}
for _, m := range models {
start := time.Now()
response, err := p.chat(ctx, m.Name, messages)
duration := time.Since(start)
sb.WriteString(fmt.Sprintf("\n**%s** %.1fs\n", m.Name, duration.Seconds()))
if err != nil {
sb.WriteString("_Fehler_\n")
} else {
// Truncate long responses
if len(response) > 500 {
response = response[:500] + "..."
}
sb.WriteString(response)
sb.WriteByte('\n')
}
sb.WriteString("\n---\n")
}
// Edit the initial message with results
if eventID != "" {
mc.Client.EditMessage(ctx, mc.RoomID, eventID, sb.String())
} else {
mc.Client.SendMessage(ctx, mc.RoomID, sb.String())
}
return nil
}
func (p *OllamaPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
session := p.getSession(mc.Sender)
// Check connection
connStatus := "❌ Offline"
modelCount := 0
models, err := p.listModels(ctx)
if err == nil {
connStatus = "✅ Online"
modelCount = len(models)
}
response := fmt.Sprintf("**Ollama Status**\n\n**Verbindung:** %s\n**Modelle:** %d\n**Dein Modell:** `%s`\n**Chat-Verlauf:** %d Nachrichten\n**DSGVO:** Alle Daten lokal",
connStatus, modelCount, session.Model, len(session.History))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *OllamaPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🤖 Ollama Bot - Befehle**
**Chat:**
Einfach eine Nachricht schreiben der Bot antwortet mit KI.
**Modelle:**
` + "`!models`" + ` Verfügbare Modelle
` + "`!model gemma3:4b`" + ` Modell wechseln
` + "`!all Was ist 2+2?`" + ` Alle Modelle vergleichen
**Modi:**
` + "`!mode code`" + ` Code-Modus
` + "`!mode translate`" + ` Übersetzer
` + "`!mode summarize`" + ` Zusammenfasser
**System:**
` + "`!clear`" + ` Chat-Verlauf löschen
` + "`!status`" + ` Ollama-Status
Alle Daten bleiben lokal (DSGVO-konform).`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Ollama API ---
func (p *OllamaPlugin) chat(ctx context.Context, model string, messages []ChatMessage) (string, error) {
body := OllamaChatRequest{
Model: model,
Messages: messages,
Stream: false,
}
data, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", p.ollamaURL+"/api/chat", bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ollama error %d: %s", resp.StatusCode, string(respBody))
}
var result OllamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode ollama response: %w", err)
}
return result.Message.Content, nil
}
func (p *OllamaPlugin) listModels(ctx context.Context) ([]OllamaModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", p.ollamaURL+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list models: %d", resp.StatusCode)
}
var result OllamaModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
// --- Session Management ---
func (p *OllamaPlugin) getSession(userID string) *UserSession {
p.mu.RLock()
session, ok := p.sessions[userID]
p.mu.RUnlock()
if !ok {
return &UserSession{
Model: p.defaultModel,
Mode: "default",
}
}
return session
}
// --- System Prompts ---
func getSystemPrompt(mode string) string {
switch mode {
case "code":
return "Du bist ein erfahrener Programmierer. Antworte mit klaren Code-Beispielen und Erklärungen. Nutze Markdown-Codeblöcke."
case "translate":
return "Du bist ein Übersetzer. Übersetze den Text in die jeweils andere Sprache (Deutsch↔Englisch). Gib nur die Übersetzung zurück."
case "summarize":
return "Du bist ein Zusammenfasser. Fasse den Text kurz und prägnant zusammen. Nutze Stichpunkte wenn sinnvoll."
default:
return "Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, außer der Nutzer schreibt auf Englisch."
}
}

View file

@ -0,0 +1,61 @@
package onboarding
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
)
func init() {
plugin.Register("onboarding", func() plugin.Plugin { return &OnboardingPlugin{} })
}
type OnboardingPlugin struct {
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *OnboardingPlugin) Name() string { return "onboarding" }
func (p *OnboardingPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!start", p.cmdStart)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("onboarding plugin initialized")
return nil
}
func (p *OnboardingPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!start"}, Description: "Onboarding starten", Category: "Onboarding"},
}
}
func (p *OnboardingPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *OnboardingPlugin) cmdStart(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID,
"**👋 Willkommen bei ManaCore!**\n\nIch helfe dir bei den ersten Schritten.\n\n1. Erstelle einen Account: `!register`\n2. Melde dich an: `!login email passwort`\n3. Erkunde die Apps!")
return nil
}
func (p *OnboardingPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Onboarding Bot:** ✅ Online")
return nil
}
func (p *OnboardingPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**👋 Onboarding Bot**\n\n• `!start` — Onboarding starten\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,74 @@
package picture
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("picture", func() plugin.Plugin { return &PicturePlugin{} })
}
type PicturePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *PicturePlugin) Name() string { return "picture" }
func (p *PicturePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!generate", p.cmdGenerate)
p.router.Handle("!generiere", p.cmdGenerate)
p.router.Handle("!bild", p.cmdGenerate)
p.router.Handle("!gallery", p.cmdGallery)
p.router.Handle("!galerie", p.cmdGallery)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("picture plugin initialized")
return nil
}
func (p *PicturePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!generate", "!bild"}, Description: "Bild generieren", Category: "Bilder"},
{Patterns: []string{"!gallery", "!galerie"}, Description: "Galerie anzeigen", Category: "Bilder"},
}
}
func (p *PicturePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *PicturePlugin) cmdGenerate(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🎨 Bildgenerierung**\n\n_Beschreibe das gewünschte Bild._")
return nil
}
func (p *PicturePlugin) cmdGallery(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🖼️ Galerie**\n\n_Verfügbar über die Web-App._")
return nil
}
func (p *PicturePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Picture Bot:** ✅ Online")
return nil
}
func (p *PicturePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🎨 Picture Bot**\n\n• `!bild [beschreibung]` — Bild generieren\n• `!galerie` — Galerie\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,543 @@
package planta
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("planta", func() plugin.Plugin { return &PlantaPlugin{} })
}
// Plant represents a plant from the backend.
type Plant struct {
ID string `json:"id"`
Name string `json:"name"`
ScientificName *string `json:"scientificName"`
Light *string `json:"light"`
WaterInterval *int `json:"waterInterval"` // days
Humidity *string `json:"humidity"`
Temperature *string `json:"temperature"`
Soil *string `json:"soil"`
Health string `json:"health"` // healthy, needs_attention, sick
Notes *string `json:"notes"`
AcquiredAt *string `json:"acquiredAt"`
}
// WateringEntry represents a watering event.
type WateringEntry struct {
Date string `json:"date"`
Notes *string `json:"notes"`
}
// UpcomingWatering represents when a plant needs water.
type UpcomingWatering struct {
PlantID string `json:"plantId"`
PlantName string `json:"plantName"`
DueDays int `json:"dueDays"` // negative = overdue
}
// PlantaPlugin implements the Matrix plant care bot.
type PlantaPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
lastList map[string][]Plant // per-user
}
func (p *PlantaPlugin) Name() string { return "planta" }
func (p *PlantaPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("planta plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastList = make(map[string][]Plant)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!pflanzen", p.cmdList)
p.router.Handle("!plants", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!pflanze", p.cmdDetails)
p.router.Handle("!plant", p.cmdDetails)
p.router.Handle("!details", p.cmdDetails)
p.router.Handle("!neu", p.cmdCreate)
p.router.Handle("!new", p.cmdCreate)
p.router.Handle("!add", p.cmdCreate)
p.router.Handle("!loeschen", p.cmdDelete)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!entfernen", p.cmdDelete)
p.router.Handle("!edit", p.cmdEdit)
p.router.Handle("!bearbeiten", p.cmdEdit)
p.router.Handle("!giessen", p.cmdWater)
p.router.Handle("!water", p.cmdWater)
p.router.Handle("!faellig", p.cmdDue)
p.router.Handle("!due", p.cmdDue)
p.router.Handle("!upcoming", p.cmdDue)
p.router.Handle("!historie", p.cmdHistory)
p.router.Handle("!history", p.cmdHistory)
p.router.Handle("!verlauf", p.cmdHistory)
p.router.Handle("!intervall", p.cmdInterval)
p.router.Handle("!interval", p.cmdInterval)
p.router.Handle("!frequenz", p.cmdInterval)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"pflanzen", "plants", "meine pflanzen"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"giessen", "water", "bewässern", "wasser geben"}, Command: "water"},
plugin.KeywordCommand{Keywords: []string{"fällig", "due", "anstehend"}, Command: "due"},
plugin.KeywordCommand{Keywords: []string{"neue pflanze"}, Command: "create"},
plugin.KeywordCommand{Keywords: []string{"historie", "history", "verlauf"}, Command: "history"},
))
slog.Info("planta plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *PlantaPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!pflanzen", "!plants"}, Description: "Alle Pflanzen", Category: "Pflanzen"},
{Patterns: []string{"!pflanze [nr]"}, Description: "Pflanzen-Details", Category: "Pflanzen"},
{Patterns: []string{"!neu [name]"}, Description: "Neue Pflanze", Category: "Pflanzen"},
{Patterns: []string{"!giessen [nr]"}, Description: "Pflanze gießen", Category: "Pflege"},
{Patterns: []string{"!fällig", "!due"}, Description: "Gieß-Status", Category: "Pflege"},
{Patterns: []string{"!historie [nr]"}, Description: "Gieß-Verlauf", Category: "Pflege"},
{Patterns: []string{"!intervall [nr] [tage]"}, Description: "Gieß-Intervall setzen", Category: "Pflege"},
{Patterns: []string{"!edit [nr] [feld] [wert]"}, Description: "Pflanze bearbeiten", Category: "Pflanzen"},
{Patterns: []string{"!delete [nr]"}, Description: "Pflanze löschen", Category: "Pflanzen"},
}
}
func (p *PlantaPlugin) 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 "list":
return p.cmdList(mc, "")
case "water":
return p.cmdWater(mc, "")
case "due":
return p.cmdDue(mc, "")
case "create":
return p.cmdCreate(mc, mc.Body)
case "history":
return p.cmdHistory(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *PlantaPlugin) cmdList(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 plants []Plant
if err := p.backend.Get(ctx, "/api/plants", token, &plants); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanzen konnten nicht geladen werden.")
return nil
}
if len(plants) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Pflanzen vorhanden.\n\nNeue Pflanze: `!neu Monstera`")
return nil
}
p.lastList[mc.Sender] = plants
var sb strings.Builder
sb.WriteString("**🌱 Deine Pflanzen:**\n\n")
for i, plant := range plants {
icon := healthIcon(plant.Health)
sb.WriteString(fmt.Sprintf("**%d.** %s **%s**", i+1, icon, plant.Name))
if plant.ScientificName != nil && *plant.ScientificName != "" {
sb.WriteString(fmt.Sprintf(" *(%s)*", *plant.ScientificName))
}
sb.WriteByte('\n')
}
sb.WriteString("\nNutze `!pflanze [Nr]` für Details oder `!fällig` für Gieß-Status")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatPlantDetails(plant))
return nil
}
func (p *PlantaPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Pflanzennamen an.\n\nBeispiel: `!neu Monstera Deliciosa`")
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
}
body := map[string]string{"name": args}
var plant Plant
if err := p.backend.Post(ctx, "/api/plants", token, body, &plant); err != nil {
slog.Error("create plant failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht erstellt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🌱 Pflanze **%s** erstellt!\n\nNutze `!pflanzen` für die Liste.", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
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.")
return nil
}
if err := p.backend.Delete(ctx, "/api/plants/"+plant.ID, token); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht gelöscht werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdEdit(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 3)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!edit [Nr] [Feld] [Wert]`\n\nFelder: name, art, licht, wasser, feuchtigkeit, temperatur, erde, notizen")
return nil
}
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
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.")
return nil
}
field := mapPlantField(parts[1])
body := map[string]string{field: parts[2]}
if err := p.backend.Put(ctx, "/api/plants/"+plant.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht aktualisiert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s** aktualisiert: **%s** = %s", plant.Name, field, parts[2]))
return nil
}
func (p *PlantaPlugin) cmdWater(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 2)
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Pflanzennummer an.\n\nBeispiel: `!giessen 1`")
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.")
return nil
}
body := map[string]string{}
if len(parts) > 1 {
body["notes"] = parts[1]
}
if err := p.backend.Post(ctx, "/api/watering/"+plant.ID+"/water", token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Gießen konnte nicht gespeichert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("💧 **%s** gegossen!", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdDue(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 upcoming []UpcomingWatering
if err := p.backend.Get(ctx, "/api/watering/upcoming", token, &upcoming); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Gieß-Status konnte nicht geladen werden.")
return nil
}
if len(upcoming) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Pflanzen mit Gieß-Intervall.")
return nil
}
var sb strings.Builder
sb.WriteString("**💧 Gieß-Status:**\n\n")
for _, w := range upcoming {
if w.DueDays < 0 {
sb.WriteString(fmt.Sprintf("• **%s**: **Überfällig (%d Tage)**\n", w.PlantName, -w.DueDays))
} else if w.DueDays == 0 {
sb.WriteString(fmt.Sprintf("• **%s**: **Heute**\n", w.PlantName))
} else {
sb.WriteString(fmt.Sprintf("• **%s**: in %d Tagen\n", w.PlantName, w.DueDays))
}
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdHistory(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Pflanzennummer an.\n\nBeispiel: `!historie 1`")
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.")
return nil
}
var history []WateringEntry
if err := p.backend.Get(ctx, "/api/watering/"+plant.ID+"/history", token, &history); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Verlauf konnte nicht geladen werden.")
return nil
}
if len(history) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Kein Gieß-Verlauf für %s.", plant.Name))
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**💧 Gieß-Historie: %s**\n\n", plant.Name))
limit := len(history)
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
entry := history[i]
sb.WriteString(fmt.Sprintf("• %s", entry.Date))
if entry.Notes != nil && *entry.Notes != "" {
sb.WriteString(fmt.Sprintf(" - %s", *entry.Notes))
}
sb.WriteByte('\n')
}
if len(history) > 10 {
sb.WriteString(fmt.Sprintf("\n_...und %d weitere Einträge_", len(history)-10))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdInterval(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!intervall [Nr] [Tage]`\n\nBeispiel: `!intervall 1 7`")
return nil
}
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
days, err := strconv.Atoi(parts[1])
if err != nil || days < 1 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Bitte gib eine gültige Anzahl Tage an.")
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.")
return nil
}
body := map[string]int{"intervalDays": days}
if err := p.backend.Put(ctx, "/api/watering/"+plant.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Intervall konnte nicht gesetzt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s**: Gieß-Intervall auf %d Tage gesetzt.", plant.Name, days))
return nil
}
func (p *PlantaPlugin) 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("**🌱 Planta Bot Status**\n\n%s", status))
return nil
}
func (p *PlantaPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🌱 Planta Bot - Befehle**
**Pflanzen:**
` + "`!pflanzen`" + ` Alle Pflanzen
` + "`!pflanze 1`" + ` Details zu Pflanze #1
` + "`!neu Monstera`" + ` Neue Pflanze
` + "`!edit 1 licht hell`" + ` Pflanze bearbeiten
` + "`!delete 1`" + ` Pflanze löschen
**Pflege:**
` + "`!giessen 1`" + ` Pflanze #1 gießen
` + "`!fällig`" + ` Gieß-Status
` + "`!historie 1`" + ` Gieß-Verlauf
` + "`!intervall 1 7`" + ` Alle 7 Tage gießen
**Felder:** name, art, licht, wasser, feuchtigkeit, temperatur, erde, notizen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Helpers ---
func (p *PlantaPlugin) getPlantByNumber(mc *plugin.MessageContext, args string) (*Plant, error) {
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
return nil, fmt.Errorf("Bitte gib eine gültige Nummer an.")
}
list, ok := p.lastList[mc.Sender]
if !ok || len(list) == 0 {
return nil, fmt.Errorf("Bitte zeige zuerst die Liste an: `!pflanzen`")
}
if num > len(list) {
return nil, fmt.Errorf("❌ Pflanze #%d nicht gefunden.", num)
}
return &list[num-1], nil
}
func formatPlantDetails(plant *Plant) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%s %s**\n\n", healthIcon(plant.Health), plant.Name))
if plant.ScientificName != nil && *plant.ScientificName != "" {
sb.WriteString(fmt.Sprintf("*%s*\n\n", *plant.ScientificName))
}
if plant.Light != nil && *plant.Light != "" {
sb.WriteString(fmt.Sprintf("• ☀️ Licht: %s\n", *plant.Light))
}
if plant.WaterInterval != nil {
sb.WriteString(fmt.Sprintf("• 💧 Gießen: alle %d Tage\n", *plant.WaterInterval))
}
if plant.Humidity != nil && *plant.Humidity != "" {
sb.WriteString(fmt.Sprintf("• 💨 Feuchtigkeit: %s\n", *plant.Humidity))
}
if plant.Temperature != nil && *plant.Temperature != "" {
sb.WriteString(fmt.Sprintf("• 🌡️ Temperatur: %s\n", *plant.Temperature))
}
if plant.Soil != nil && *plant.Soil != "" {
sb.WriteString(fmt.Sprintf("• 🪴 Erde: %s\n", *plant.Soil))
}
sb.WriteString(fmt.Sprintf("• %s Gesundheit: %s\n", healthIcon(plant.Health), plant.Health))
if plant.Notes != nil && *plant.Notes != "" {
sb.WriteString(fmt.Sprintf("\n**Notizen:** %s\n", *plant.Notes))
}
return sb.String()
}
func healthIcon(health string) string {
switch health {
case "healthy":
return "💚"
case "needs_attention":
return "⚠️"
case "sick":
return "🔴"
default:
return "💚"
}
}
func mapPlantField(input string) string {
switch strings.ToLower(input) {
case "name":
return "name"
case "art", "wissenschaftlich", "scientific":
return "scientificName"
case "licht", "light":
return "light"
case "wasser", "water":
return "waterInterval"
case "feuchtigkeit", "humidity":
return "humidity"
case "temperatur", "temperature":
return "temperature"
case "erde", "soil":
return "soil"
case "notizen", "notes":
return "notes"
default:
return input
}
}

View file

@ -0,0 +1,66 @@
package presi
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("presi", func() plugin.Plugin { return &PresiPlugin{} })
}
type PresiPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *PresiPlugin) Name() string { return "presi" }
func (p *PresiPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!presentations", p.cmdList)
p.router.Handle("!präsentationen", p.cmdList)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("presi plugin initialized")
return nil
}
func (p *PresiPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!presentations"}, Description: "Präsentationen", Category: "Presi"},
}
}
func (p *PresiPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *PresiPlugin) cmdList(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📊 Präsentationen**\n\n_Verwaltung über die Web-App._")
return nil
}
func (p *PresiPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Presi Bot:** ✅ Online")
return nil
}
func (p *PresiPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📊 Presi Bot**\n\n• `!präsentationen` — Liste\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,71 @@
package projectdoc
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("projectdoc", func() plugin.Plugin { return &ProjectDocPlugin{} })
}
type ProjectDocPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ProjectDocPlugin) Name() string { return "projectdoc" }
func (p *ProjectDocPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!docs", p.cmdDocs)
p.router.Handle("!generate", p.cmdGenerate)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("projectdoc plugin initialized")
return nil
}
func (p *ProjectDocPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!docs"}, Description: "Dokumentation anzeigen", Category: "Docs"},
{Patterns: []string{"!generate"}, Description: "Doku generieren", Category: "Docs"},
}
}
func (p *ProjectDocPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *ProjectDocPlugin) cmdDocs(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 Dokumentation**\n\n_Projekt-Dokumentation über die Web-App._")
return nil
}
func (p *ProjectDocPlugin) cmdGenerate(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 Doku generieren**\n\n_Beschreibe das zu dokumentierende Projekt._")
return nil
}
func (p *ProjectDocPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**ProjectDoc Bot:** ✅ Online")
return nil
}
func (p *ProjectDocPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 ProjectDoc Bot**\n\n• `!docs` — Dokumentation\n• `!generate` — Doku generieren\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,66 @@
package questions
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("questions", func() plugin.Plugin { return &QuestionsPlugin{} })
}
type QuestionsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *QuestionsPlugin) Name() string { return "questions" }
func (p *QuestionsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!frage", p.cmdAsk)
p.router.Handle("!ask", p.cmdAsk)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("questions plugin initialized")
return nil
}
func (p *QuestionsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!frage", "!ask"}, Description: "Frage stellen", Category: "Q&A"},
}
}
func (p *QuestionsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *QuestionsPlugin) cmdAsk(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**❓ Fragen**\n\n_Q&A-System über die Web-App verfügbar._")
return nil
}
func (p *QuestionsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Questions Bot:** ✅ Online")
return nil
}
func (p *QuestionsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**❓ Questions Bot**\n\n• `!frage [text]` — Frage stellen\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,65 @@
package skilltree
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("skilltree", func() plugin.Plugin { return &SkilltreePlugin{} })
}
type SkilltreePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *SkilltreePlugin) Name() string { return "skilltree" }
func (p *SkilltreePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!skills", p.cmdSkills)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("skilltree plugin initialized")
return nil
}
func (p *SkilltreePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!skills"}, Description: "Skill-Übersicht", Category: "Skilltree"},
}
}
func (p *SkilltreePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *SkilltreePlugin) cmdSkills(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🌳 Skills**\n\n_Skill-Verwaltung über die Web-App._")
return nil
}
func (p *SkilltreePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Skilltree Bot:** ✅ Online")
return nil
}
func (p *SkilltreePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🌳 Skilltree Bot**\n\n• `!skills` — Übersicht\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,79 @@
package stats
import (
"context"
"fmt"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("stats", func() plugin.Plugin { return &StatsPlugin{} })
}
// StatsPlugin reports system statistics via Matrix.
type StatsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *StatsPlugin) Name() string { return "stats" }
func (p *StatsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!stats", p.cmdStats)
p.router.Handle("!report", p.cmdStats)
p.router.Handle("!bericht", p.cmdStats)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("stats plugin initialized")
return nil
}
func (p *StatsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!stats", "!report"}, Description: "Systemstatistiken", Category: "Stats"},
{Patterns: []string{"!status"}, Description: "Bot-Status", Category: "System"},
}
}
func (p *StatsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
if cmd == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *StatsPlugin) cmdStats(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**📊 Systemstatistiken**\n\n_Verfügbar wenn VictoriaMetrics verbunden._")
return nil
}
func (p *StatsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Stats Bot:** ✅ Online")
return nil
}
func (p *StatsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**📊 Stats Bot**\n\n• `!stats` — Systemstatistiken\n• `!status` — Bot-Status"))
return nil
}

View file

@ -0,0 +1,66 @@
package storage
import (
"context"
"log/slog"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("storage", func() plugin.Plugin { return &StoragePlugin{} })
}
type StoragePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *StoragePlugin) Name() string { return "storage" }
func (p *StoragePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.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("!files", p.cmdFiles)
p.router.Handle("!dateien", p.cmdFiles)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("storage plugin initialized")
return nil
}
func (p *StoragePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!files", "!dateien"}, Description: "Dateien anzeigen", Category: "Storage"},
}
}
func (p *StoragePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *StoragePlugin) cmdFiles(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📁 Dateien**\n\n_Dateiverwaltung über die Web-App._")
return nil
}
func (p *StoragePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Storage Bot:** ✅ Online")
return nil
}
func (p *StoragePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📁 Storage Bot**\n\n• `!dateien` — Dateien\n• `!status` — Status")
return nil
}

View file

@ -0,0 +1,200 @@
package stt
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("stt", func() plugin.Plugin { return &STTPlugin{} })
}
// UserSettings holds per-user STT preferences.
type UserSettings struct {
Language string // de, en, auto
Model string // whisper, voxtral, auto
}
// STTPlugin implements the Matrix speech-to-text bot.
type STTPlugin struct {
voice *services.VoiceClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
mu sync.RWMutex
settings map[string]*UserSettings
}
func (p *STTPlugin) Name() string { return "stt" }
func (p *STTPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
sttURL := cfg.Extra["stt_url"]
if sttURL == "" {
sttURL = "http://localhost:3020"
}
p.voice = services.NewVoiceClient(sttURL, "")
p.settings = make(map[string]*UserSettings)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!language", p.cmdLanguage)
p.router.Handle("!sprache", p.cmdLanguage)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!modell", p.cmdModel)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"sprache", "sprache ändern"}, Command: "language"},
plugin.KeywordCommand{Keywords: []string{"modell"}, Command: "model"},
))
slog.Info("stt plugin initialized", "url", sttURL)
return nil
}
func (p *STTPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!language [de|en|auto]"}, Description: "Sprache ändern", Category: "Einstellungen"},
{Patterns: []string{"!model [whisper|voxtral]"}, Description: "STT-Modell ändern", Category: "Einstellungen"},
{Patterns: []string{"!status"}, Description: "Aktuelle Einstellungen", Category: "System"},
}
}
func (p *STTPlugin) 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 "language":
return p.cmdLanguage(mc, "")
case "model":
return p.cmdModel(mc, "")
}
// STT bot only responds to commands and audio — ignore other text
return nil
}
// HandleAudioMessage transcribes audio messages.
func (p *STTPlugin) HandleAudioMessage(ctx context.Context, mc *plugin.MessageContext, audioData []byte) error {
settings := p.getSettings(mc.Sender)
result, err := p.voice.Transcribe(ctx, audioData, settings.Language)
if err != nil {
slog.Error("transcription failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Transkription fehlgeschlagen.")
return nil
}
if strings.TrimSpace(result.Text) == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🎤 Ich konnte nichts verstehen.")
return nil
}
response := fmt.Sprintf("**Transkription:**\n\n%s\n\n*Sprache: %s | Dauer: %.1fs*",
result.Text, result.Language, result.Duration)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
// --- Command Handlers ---
func (p *STTPlugin) cmdLanguage(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validLangs := map[string]bool{"de": true, "en": true, "auto": true}
if args == "" || !validLangs[args] {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Sprache:** `%s`\n\n**Verwendung:** `!language [de|en|auto]`", settings.Language))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Language = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Sprache auf `%s` gesetzt.", args))
return nil
}
func (p *STTPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validModels := map[string]bool{"whisper": true, "voxtral": true, "auto": true}
if args == "" || !validModels[args] {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelles Modell:** `%s`\n\n**Verwendung:** `!model [whisper|voxtral|auto]`\n\n**Modelle:**\n• `whisper` — Whisper Large V3 (lokal, schnell)\n• `voxtral` — Voxtral Mini (Cloud, Speaker Diarization)\n• `auto` — Automatische Auswahl", settings.Model))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Model = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell auf `%s` gesetzt.", args))
return nil
}
func (p *STTPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Einstellungen:**\n\nSprache: `%s`\nModell: `%s`", settings.Language, settings.Model))
return nil
}
func (p *STTPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🎤 STT Bot - Sprache zu Text**
Sende eine **Sprachnachricht** und ich transkribiere sie!
**Einstellungen:**
` + "`!language de`" + ` Sprache (de, en, auto)
` + "`!model whisper`" + ` Modell (whisper, voxtral, auto)
` + "`!status`" + ` Aktuelle Einstellungen
**Modelle:**
**Whisper** Lokal, schnell, gut für Deutsch/Englisch
**Voxtral** Cloud, Speaker Diarization`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Settings ---
func (p *STTPlugin) getSettings(userID string) *UserSettings {
p.mu.RLock()
settings, ok := p.settings[userID]
p.mu.RUnlock()
if !ok {
return &UserSettings{
Language: "de",
Model: "whisper",
}
}
return settings
}

View file

@ -0,0 +1,553 @@
package todo
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("todo", func() plugin.Plugin { return &TodoPlugin{} })
}
// Task represents a todo task from the backend.
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
Priority int `json:"priority"`
DueDate *string `json:"dueDate"`
Project *string `json:"project"`
CompletedAt *string `json:"completedAt"`
}
// TaskStats holds task statistics.
type TaskStats struct {
Total int `json:"total"`
Pending int `json:"pending"`
Completed int `json:"completed"`
Today int `json:"today"`
}
// CreateTaskInput is the request body for creating a task.
type CreateTaskInput struct {
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
DueDate *string `json:"dueDate,omitempty"`
}
// TodoPlugin implements the Matrix todo bot.
type TodoPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *TodoPlugin) Name() string { return "todo" }
func (p *TodoPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("todo plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
// Command router
p.router = plugin.NewCommandRouter()
p.router.Handle("!todo", p.cmdAdd)
p.router.Handle("!add", p.cmdAdd)
p.router.Handle("!neu", p.cmdAdd)
p.router.Handle("!list", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!alle", p.cmdList)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!inbox", p.cmdInbox)
p.router.Handle("!eingang", p.cmdInbox)
p.router.Handle("!done", p.cmdDone)
p.router.Handle("!erledigt", p.cmdDone)
p.router.Handle("!fertig", p.cmdDone)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!löschen", p.cmdDelete)
p.router.Handle("!entfernen", p.cmdDelete)
p.router.Handle("!projects", p.cmdProjects)
p.router.Handle("!projekte", p.cmdProjects)
p.router.Handle("!status", p.cmdStatus)
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
// Keyword detector
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"zeige aufgaben", "show tasks"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"heute", "today"}, Command: "today"},
plugin.KeywordCommand{Keywords: []string{"inbox", "eingang"}, Command: "inbox"},
plugin.KeywordCommand{Keywords: []string{"projekte", "projects"}, Command: "projects"},
plugin.KeywordCommand{Keywords: []string{"neu", "neue", "add"}, Command: "add"},
plugin.KeywordCommand{Keywords: []string{"erledigt", "fertig", "done"}, Command: "done"},
plugin.KeywordCommand{Keywords: []string{"löschen", "entfernen", "delete"}, Command: "delete"},
))
slog.Info("todo plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *TodoPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!todo", "!add", "!neu"}, Description: "Aufgabe erstellen", Category: "Aufgaben"},
{Patterns: []string{"!list", "!liste"}, Description: "Alle offenen Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!today", "!heute"}, Description: "Heutige Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!inbox", "!eingang"}, Description: "Aufgaben ohne Datum", Category: "Aufgaben"},
{Patterns: []string{"!done", "!erledigt"}, Description: "Aufgabe erledigen", Category: "Aufgaben"},
{Patterns: []string{"!delete", "!löschen"}, Description: "Aufgabe löschen", Category: "Aufgaben"},
{Patterns: []string{"!projects", "!projekte"}, Description: "Projekte anzeigen", Category: "Aufgaben"},
{Patterns: []string{"!status"}, Description: "Verbindungsstatus", Category: "System"},
{Patterns: []string{"!help", "!hilfe"}, Description: "Hilfe anzeigen", Category: "System"},
}
}
func (p *TodoPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keyword detection
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdList(mc, "")
case "today":
return p.cmdToday(mc, "")
case "inbox":
return p.cmdInbox(mc, "")
case "projects":
return p.cmdProjects(mc, "")
case "add":
return p.cmdAdd(mc, mc.Body)
case "done":
return p.cmdDone(mc, "")
case "delete":
return p.cmdDelete(mc, "")
}
// Fallback: treat as new task
return p.cmdAdd(mc, mc.Body)
}
// --- Command Handlers ---
func (p *TodoPlugin) cmdAdd(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Aufgabe an.\n\nBeispiel: `!todo Einkaufen @morgen !p1`")
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, priority, dueDate, project := parseTaskInput(args)
input := CreateTaskInput{
Title: title,
Priority: priority,
DueDate: dueDate,
}
var task Task
if err := p.backend.Post(ctx, "/api/tasks", token, input, &task); err != nil {
slog.Error("create task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erstellt werden.")
return nil
}
// Build response
response := fmt.Sprintf("✅ Aufgabe erstellt: **%s**", task.Title)
var details []string
if priority < 4 {
details = append(details, fmt.Sprintf("Priorität %d", priority))
}
if dueDate != nil {
details = append(details, formatDate(*dueDate))
}
if project != "" {
details = append(details, "#"+project)
}
if len(details) > 0 {
response += " · " + strings.Join(details, " · ")
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *TodoPlugin) cmdList(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 tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
slog.Error("get tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine offenen Aufgaben.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📋 **Alle offenen Aufgaben:**", tasks))
return nil
}
func (p *TodoPlugin) cmdToday(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 tasks []Task
if err := p.backend.Get(ctx, "/api/tasks/today", token, &tasks); err != nil {
slog.Error("get today tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Aufgaben für heute.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📅 **Heutige Aufgaben:**", tasks))
return nil
}
func (p *TodoPlugin) cmdInbox(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 tasks []Task
if err := p.backend.Get(ctx, "/api/tasks/inbox", token, &tasks); err != nil {
slog.Error("get inbox tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Aufgaben im Eingang.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📥 **Eingang:**", tasks))
return nil
}
func (p *TodoPlugin) cmdDone(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 Aufgabennummer an.\n\nBeispiel: `!done 1`")
return nil
}
// Get current task list to find the task by number
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if num > len(tasks) {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Aufgabe #%d nicht gefunden.", num))
return nil
}
task := tasks[num-1]
var completed Task
if err := p.backend.Put(ctx, "/api/tasks/"+task.ID+"/complete", token, nil, &completed); err != nil {
slog.Error("complete task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erledigt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ ~~%s~~", task.Title))
return nil
}
func (p *TodoPlugin) 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 Aufgabennummer an.\n\nBeispiel: `!delete 1`")
return nil
}
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if num > len(tasks) {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Aufgabe #%d nicht gefunden.", num))
return nil
}
task := tasks[num-1]
if err := p.backend.Delete(ctx, "/api/tasks/"+task.ID, token); err != nil {
slog.Error("delete task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht gelöscht werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", task.Title))
return nil
}
func (p *TodoPlugin) cmdProjects(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 projects []struct {
Name string `json:"name"`
ID string `json:"id"`
}
if err := p.backend.Get(ctx, "/api/projects", token, &projects); err != nil {
slog.Error("get projects failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Projekte konnten nicht geladen werden.")
return nil
}
if len(projects) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Projekte vorhanden.")
return nil
}
var sb strings.Builder
sb.WriteString("**Deine Projekte:**\n\n")
for _, proj := range projects {
sb.WriteString("• ")
sb.WriteString(proj.Name)
sb.WriteByte('\n')
}
sb.WriteString("\nZeige Projektaufgaben mit `!projekt [Name]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *TodoPlugin) cmdStatus(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, "❌ Nicht angemeldet. Nutze `!login email passwort`")
return nil
}
var stats TaskStats
if err := p.backend.Get(ctx, "/api/tasks/stats", token, &stats); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Verbunden")
return nil
}
response := fmt.Sprintf("**Status**\n\n• Offen: %d\n• Heute: %d\n• Erledigt: %d",
stats.Pending, stats.Today, stats.Completed)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *TodoPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**📋 Todo Bot - Befehle**
**Aufgaben:**
` + "`!todo Einkaufen @morgen !p1`" + ` Neue Aufgabe
` + "`!list`" + ` Alle offenen Aufgaben
` + "`!today`" + ` Heutige Aufgaben
` + "`!inbox`" + ` Aufgaben ohne Datum
` + "`!done 1`" + ` Aufgabe #1 erledigen
` + "`!delete 1`" + ` Aufgabe #1 löschen
**Syntax:**
` + "`!p1`" + ` bis ` + "`!p4`" + ` Priorität (1=hoch)
` + "`@heute`" + `, ` + "`@morgen`" + `, ` + "`@2025-03-27`" + ` Datum
` + "`#projekt`" + ` Projekt zuweisen
**Projekte:**
` + "`!projekte`" + ` Alle Projekte
**System:**
` + "`!status`" + ` Verbindungsstatus
` + "`!hilfe`" + ` Diese Hilfe`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Task Formatting ---
func formatTaskList(header string, tasks []Task) string {
var sb strings.Builder
sb.WriteString(header)
sb.WriteString("\n\n")
for i, task := range tasks {
sb.WriteString(fmt.Sprintf("**%d.** %s", i+1, task.Title))
// Priority indicators
if task.Priority < 4 {
sb.WriteByte(' ')
for j := 0; j < 4-task.Priority; j++ {
sb.WriteString("❗")
}
}
// Due date
if task.DueDate != nil {
sb.WriteString(" ")
sb.WriteString(formatDate(*task.DueDate))
}
// Project
if task.Project != nil && *task.Project != "" {
sb.WriteString(" #")
sb.WriteString(*task.Project)
}
sb.WriteByte('\n')
}
sb.WriteString("\nErledigen: `!done [Nr]` | Löschen: `!delete [Nr]`")
return sb.String()
}
func formatDate(dateStr string) string {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
// Handle full ISO datetime or date-only
date := dateStr
if len(date) > 10 {
date = date[:10]
}
switch date {
case today:
return "Heute"
case tomorrow:
return "Morgen"
default:
t, err := time.Parse("2006-01-02", date)
if err != nil {
return dateStr
}
return t.Format("02.01")
}
}
// --- Input Parsing ---
var (
rePriority = regexp.MustCompile(`!p([1-4])`)
reDate = regexp.MustCompile(`@(\S+)`)
reProject = regexp.MustCompile(`#(\S+)`)
)
// parseTaskInput extracts title, priority, dueDate, project from user input.
// Syntax: "Einkaufen !p1 @morgen #haushalt"
func parseTaskInput(input string) (title string, priority int, dueDate *string, project string) {
priority = 4 // default: lowest
// Extract priority
if m := rePriority.FindStringSubmatch(input); len(m) == 2 {
p, _ := strconv.Atoi(m[1])
priority = p
}
// Extract date
if m := reDate.FindStringSubmatch(input); len(m) == 2 {
d := parseGermanDate(m[1])
if d != "" {
dueDate = &d
}
}
// Extract project
if m := reProject.FindStringSubmatch(input); len(m) == 2 {
project = m[1]
}
// Remove markers from title
title = rePriority.ReplaceAllString(input, "")
title = reDate.ReplaceAllString(title, "")
title = reProject.ReplaceAllString(title, "")
title = strings.TrimSpace(title)
return
}
// parseGermanDate converts German date keywords to ISO date strings.
func parseGermanDate(keyword string) string {
now := time.Now()
switch strings.ToLower(keyword) {
case "heute", "today":
return now.Format("2006-01-02")
case "morgen", "tomorrow":
return now.AddDate(0, 0, 1).Format("2006-01-02")
case "übermorgen", "uebermorgen":
return now.AddDate(0, 0, 2).Format("2006-01-02")
default:
// Try ISO date format
if _, err := time.Parse("2006-01-02", keyword); err == nil {
return keyword
}
return ""
}
}

View file

@ -0,0 +1,87 @@
package todo
import (
"testing"
"time"
)
func TestParseTaskInput(t *testing.T) {
tests := []struct {
input string
title string
priority int
hasDate bool
project string
}{
{"Einkaufen", "Einkaufen", 4, false, ""},
{"Einkaufen !p1", "Einkaufen", 1, false, ""},
{"Einkaufen !p2 @morgen", "Einkaufen", 2, true, ""},
{"Einkaufen !p1 @morgen #haushalt", "Einkaufen", 1, true, "haushalt"},
{"Meeting vorbereiten #arbeit", "Meeting vorbereiten", 4, false, "arbeit"},
{" Spaces !p3 ", "Spaces", 3, false, ""},
}
for _, tt := range tests {
title, priority, dueDate, project := parseTaskInput(tt.input)
if title != tt.title {
t.Errorf("parseTaskInput(%q) title = %q, want %q", tt.input, title, tt.title)
}
if priority != tt.priority {
t.Errorf("parseTaskInput(%q) priority = %d, want %d", tt.input, priority, tt.priority)
}
if tt.hasDate && dueDate == nil {
t.Errorf("parseTaskInput(%q) expected dueDate, got nil", tt.input)
}
if !tt.hasDate && dueDate != nil {
t.Errorf("parseTaskInput(%q) expected no dueDate, got %q", tt.input, *dueDate)
}
if project != tt.project {
t.Errorf("parseTaskInput(%q) project = %q, want %q", tt.input, project, tt.project)
}
}
}
func TestParseGermanDate(t *testing.T) {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tests := []struct {
input string
want string
}{
{"heute", today},
{"today", today},
{"morgen", tomorrow},
{"tomorrow", tomorrow},
{"2025-03-27", "2025-03-27"},
{"ungültig", ""},
}
for _, tt := range tests {
got := parseGermanDate(tt.input)
if got != tt.want {
t.Errorf("parseGermanDate(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatDate(t *testing.T) {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tests := []struct {
input string
want string
}{
{today, "Heute"},
{tomorrow, "Morgen"},
{"2025-12-25", "25.12"},
}
for _, tt := range tests {
got := formatDate(tt.input)
if got != tt.want {
t.Errorf("formatDate(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View file

@ -0,0 +1,253 @@
package tts
import (
"context"
"fmt"
"log/slog"
"strconv"
"sync"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("tts", func() plugin.Plugin { return &TTSPlugin{} })
}
// UserSettings holds per-user TTS preferences.
type UserSettings struct {
Voice string
Speed float64
}
// TTSPlugin implements the Matrix text-to-speech bot.
type TTSPlugin struct {
voice *services.VoiceClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
maxLen int
mu sync.RWMutex
settings map[string]*UserSettings
}
func (p *TTSPlugin) Name() string { return "tts" }
func (p *TTSPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
ttsURL := cfg.Extra["tts_url"]
if ttsURL == "" {
ttsURL = "http://localhost:3022"
}
p.voice = services.NewVoiceClient("", ttsURL)
p.settings = make(map[string]*UserSettings)
p.maxLen = 500
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!voice", p.cmdVoice)
p.router.Handle("!stimme", p.cmdVoice)
p.router.Handle("!voices", p.cmdVoices)
p.router.Handle("!stimmen", p.cmdVoices)
p.router.Handle("!speed", p.cmdSpeed)
p.router.Handle("!geschwindigkeit", p.cmdSpeed)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"stimme", "stimme ändern"}, Command: "voice"},
plugin.KeywordCommand{Keywords: []string{"stimmen", "verfügbare stimmen"}, Command: "voices"},
plugin.KeywordCommand{Keywords: []string{"geschwindigkeit", "tempo"}, Command: "speed"},
))
slog.Info("tts plugin initialized", "url", ttsURL)
return nil
}
func (p *TTSPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!voice [name]", "!stimme"}, Description: "Stimme ändern", Category: "Einstellungen"},
{Patterns: []string{"!voices", "!stimmen"}, Description: "Verfügbare Stimmen", Category: "Einstellungen"},
{Patterns: []string{"!speed [0.5-2.0]"}, Description: "Geschwindigkeit", Category: "Einstellungen"},
{Patterns: []string{"!status"}, Description: "Aktuelle Einstellungen", Category: "System"},
}
}
func (p *TTSPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "voice":
return p.cmdVoice(mc, "")
case "voices":
return p.cmdVoices(mc, "")
case "speed":
return p.cmdSpeed(mc, "")
}
// Default: treat as text to synthesize
return p.synthesize(mc, mc.Body)
}
// --- Synthesis ---
func (p *TTSPlugin) synthesize(mc *plugin.MessageContext, text string) error {
ctx := context.Background()
if len(text) > p.maxLen {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("❌ Text zu lang (%d Zeichen). Maximum: %d Zeichen.", len(text), p.maxLen))
return nil
}
settings := p.getSettings(mc.Sender)
audioData, err := p.voice.Synthesize(ctx, text, settings.Voice)
if err != nil {
slog.Error("tts synthesis failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Sprachsynthese fehlgeschlagen.")
return nil
}
// Upload audio to Matrix
mxcURL, err := mc.Client.UploadMedia(ctx, audioData, "audio/wav", "speech.wav")
if err != nil {
slog.Error("upload audio failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Audio-Upload fehlgeschlagen.")
return nil
}
// Send audio message
_, err = mc.Client.SendAudio(ctx, mc.RoomID, mxcURL, "speech.wav", len(audioData))
if err != nil {
slog.Error("send audio failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Audio konnte nicht gesendet werden.")
return nil
}
return nil
}
// --- Command Handlers ---
func (p *TTSPlugin) cmdVoice(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Stimme:** `%s`\n\n**Verwendung:** `!voice [name]`\n\nZeige alle: `!voices`", settings.Voice))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Voice = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Stimme auf `%s` gesetzt.", args))
return nil
}
func (p *TTSPlugin) cmdVoices(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
// Known voices (static list — the TTS service auto-discovers)
help := `**Verfügbare Stimmen:**
**Kokoro (Englisch, schnell):**
` + "`af_heart`" + ` Weiblich (Standard)
` + "`af_bella`" + ` Weiblich
` + "`am_michael`" + ` Männlich
` + "`bm_daniel`" + ` Männlich
` + "`bf_emma`" + ` Weiblich
**Piper (Deutsch, lokal):**
` + "`de_kerstin`" + ` Deutsch Frau
` + "`de_thorsten`" + ` Deutsch Mann
Wechseln mit: ` + "`!voice [name]`"
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
func (p *TTSPlugin) cmdSpeed(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Geschwindigkeit:** %.1fx\n\n**Verwendung:** `!speed [0.5-2.0]`", settings.Speed))
return nil
}
speed, err := strconv.ParseFloat(args, 64)
if err != nil || speed < 0.5 || speed > 2.0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Geschwindigkeit muss zwischen 0.5 und 2.0 liegen.\n\nBeispiel: `!speed 1.2`")
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Speed = speed
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Geschwindigkeit auf %.1fx gesetzt.", speed))
return nil
}
func (p *TTSPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Einstellungen:**\n\nStimme: `%s`\nGeschwindigkeit: %.1fx\nMax. Textlänge: %d Zeichen",
settings.Voice, settings.Speed, p.maxLen))
return nil
}
func (p *TTSPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🔊 TTS Bot - Text zu Sprache**
Sende eine **Textnachricht** und ich lese sie vor!
**Einstellungen:**
` + "`!voice af_heart`" + ` Stimme wechseln
` + "`!voices`" + ` Alle Stimmen anzeigen
` + "`!speed 1.2`" + ` Geschwindigkeit (0.5-2.0)
` + "`!status`" + ` Aktuelle Einstellungen
**Max. Textlänge:** 500 Zeichen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Settings ---
func (p *TTSPlugin) getSettings(userID string) *UserSettings {
p.mu.RLock()
settings, ok := p.settings[userID]
p.mu.RUnlock()
if !ok {
return &UserSettings{
Voice: "af_heart",
Speed: 1.0,
}
}
return settings
}

View file

@ -0,0 +1,392 @@
package zitare
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("zitare", func() plugin.Plugin { return &ZitarePlugin{} })
}
// Quote represents a quote from the backend.
type Quote struct {
ID string `json:"id"`
Text string `json:"text"`
Author string `json:"author"`
Category string `json:"category"`
}
// Category represents a quote category.
type Category struct {
Name string `json:"name"`
Count int `json:"count"`
}
// ZitarePlugin implements the Matrix quotes bot.
type ZitarePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
lastQuote map[string]*Quote // per-user last shown quote
}
func (p *ZitarePlugin) Name() string { return "zitare" }
func (p *ZitarePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("zitare plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastQuote = make(map[string]*Quote)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!zitat", p.cmdRandom)
p.router.Handle("!quote", p.cmdRandom)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!suche", p.cmdSearch)
p.router.Handle("!search", p.cmdSearch)
p.router.Handle("!kategorie", p.cmdCategory)
p.router.Handle("!category", p.cmdCategory)
p.router.Handle("!kategorien", p.cmdCategories)
p.router.Handle("!categories", p.cmdCategories)
p.router.Handle("!motivation", p.cmdMotivation)
p.router.Handle("!morgen", p.cmdMorning)
p.router.Handle("!favorit", p.cmdFavorite)
p.router.Handle("!fav", p.cmdFavorite)
p.router.Handle("!favoriten", p.cmdFavorites)
p.router.Handle("!favorites", p.cmdFavorites)
p.router.Handle("!listen", p.cmdLists)
p.router.Handle("!lists", p.cmdLists)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"zitat", "quote", "inspiration", "inspiriere"}, Command: "random"},
plugin.KeywordCommand{Keywords: []string{"tageszitat"}, Command: "today"},
plugin.KeywordCommand{Keywords: []string{"motiviere", "motivation", "motivier mich"}, Command: "motivation"},
plugin.KeywordCommand{Keywords: []string{"guten morgen", "good morning"}, Command: "morning"},
plugin.KeywordCommand{Keywords: []string{"kategorien", "categories", "themen"}, Command: "categories"},
plugin.KeywordCommand{Keywords: []string{"favoriten", "favorites", "meine favoriten"}, Command: "favorites"},
plugin.KeywordCommand{Keywords: []string{"listen", "lists", "meine listen"}, Command: "lists"},
))
slog.Info("zitare plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *ZitarePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!zitat", "!quote"}, Description: "Zufälliges Zitat", Category: "Zitate"},
{Patterns: []string{"!heute", "!today"}, Description: "Zitat des Tages", Category: "Zitate"},
{Patterns: []string{"!suche [text]"}, Description: "Zitate suchen", Category: "Zitate"},
{Patterns: []string{"!kategorie [name]"}, Description: "Zitat aus Kategorie", Category: "Zitate"},
{Patterns: []string{"!kategorien"}, Description: "Alle Kategorien", Category: "Zitate"},
{Patterns: []string{"!motivation"}, Description: "Motivationszitat", Category: "Zitate"},
{Patterns: []string{"!favorit"}, Description: "Letztes Zitat als Favorit", Category: "Zitate"},
{Patterns: []string{"!favoriten"}, Description: "Alle Favoriten", Category: "Zitate"},
}
}
func (p *ZitarePlugin) 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 "random":
return p.cmdRandom(mc, "")
case "today":
return p.cmdToday(mc, "")
case "motivation":
return p.cmdMotivation(mc, "")
case "morning":
return p.cmdMorning(mc, "")
case "categories":
return p.cmdCategories(mc, "")
case "favorites":
return p.cmdFavorites(mc, "")
case "lists":
return p.cmdLists(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *ZitarePlugin) cmdRandom(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/random", token, &quote); err != nil {
slog.Error("get random quote failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Zitat konnte nicht geladen werden.")
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/today", token, &quote); err != nil {
slog.Error("get today quote failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Zitat des Tages konnte nicht geladen werden.")
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdSearch(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Suchbegriff an.\n\nBeispiel: `!suche Glück`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quotes []Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/search?q="+args, token, &quotes); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Suche fehlgeschlagen.")
return nil
}
if len(quotes) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Keine Zitate für \"%s\" gefunden.", args))
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Suchergebnisse für \"%s\" (%d):**\n\n", args, len(quotes)))
limit := len(quotes)
if limit > 5 {
limit = 5
}
for i := 0; i < limit; i++ {
q := quotes[i]
sb.WriteString(fmt.Sprintf("**%d.** \"%s\"\n-- *%s*\n\n", i+1, q.Text, q.Author))
}
if len(quotes) > 5 {
sb.WriteString(fmt.Sprintf("_...und %d weitere_", len(quotes)-5))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdCategory(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Kategorie an.\n\nBeispiel: `!kategorie motivation`\nAlle Kategorien: `!kategorien`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/random?category="+args, token, &quote); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Kein Zitat in Kategorie \"%s\" gefunden.", args))
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdCategories(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var categories []Category
if err := p.backend.Get(ctx, "/api/v1/quotes/categories", token, &categories); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kategorien konnten nicht geladen werden.")
return nil
}
if len(categories) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kategorien verfügbar.")
return nil
}
var sb strings.Builder
sb.WriteString("**Verfügbare Kategorien:**\n\n")
for _, cat := range categories {
sb.WriteString(fmt.Sprintf("• **%s** (`!kategorie %s`) - %d Zitate\n", cat.Name, strings.ToLower(cat.Name), cat.Count))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdMotivation(mc *plugin.MessageContext, _ string) error {
return p.cmdCategory(mc, "motivation")
}
func (p *ZitarePlugin) cmdMorning(mc *plugin.MessageContext, _ string) error {
return p.cmdCategory(mc, "motivation")
}
func (p *ZitarePlugin) cmdFavorite(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
}
quote, ok := p.lastQuote[mc.Sender]
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein Zitat zum Speichern. Hole zuerst eines: `!zitat`")
return nil
}
body := map[string]string{"quoteId": quote.ID}
if err := p.backend.Post(ctx, "/api/v1/favorites", token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favorit konnte nicht gespeichert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⭐ Zitat als Favorit gespeichert!")
return nil
}
func (p *ZitarePlugin) cmdFavorites(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 favorites []Quote
if err := p.backend.Get(ctx, "/api/v1/favorites", token, &favorites); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favoriten konnten nicht geladen werden.")
return nil
}
if len(favorites) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Favoriten.\n\nSpeichere ein Zitat mit `!favorit` nach `!zitat`.")
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**⭐ Deine Favoriten (%d):**\n\n", len(favorites)))
limit := len(favorites)
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
q := favorites[i]
sb.WriteString(fmt.Sprintf("**%d.** \"%s\"\n-- *%s*\n\n", i+1, q.Text, q.Author))
}
if len(favorites) > 10 {
sb.WriteString(fmt.Sprintf("_...und %d weitere_", len(favorites)-10))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdLists(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 lists []struct {
Name string `json:"name"`
Count int `json:"count"`
}
if err := p.backend.Get(ctx, "/api/v1/lists", token, &lists); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Listen konnten nicht geladen werden.")
return nil
}
if len(lists) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Listen.\n\nErstelle eine mit: `!liste Meine Zitate`")
return nil
}
var sb strings.Builder
sb.WriteString("**📋 Deine Listen:**\n\n")
for i, l := range lists {
sb.WriteString(fmt.Sprintf("**%d.** %s (%d Zitate)\n", i+1, l.Name, l.Count))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) 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("**Zitare Bot Status**\n\n%s", status))
return nil
}
func (p *ZitarePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**📜 Zitare Bot - Befehle**
**Zitate:**
` + "`!zitat`" + ` Zufälliges Zitat
` + "`!heute`" + ` Zitat des Tages
` + "`!suche Glück`" + ` Zitate suchen
` + "`!kategorie motivation`" + ` Zitat aus Kategorie
` + "`!kategorien`" + ` Alle Kategorien
` + "`!motivation`" + ` Motivationszitat
**Favoriten:**
` + "`!favorit`" + ` Letztes Zitat als Favorit speichern
` + "`!favoriten`" + ` Alle Favoriten
**Listen:**
` + "`!listen`" + ` Alle Listen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Formatting ---
func formatQuote(q *Quote) string {
author := q.Author
if author == "" {
author = "Unbekannt"
}
return fmt.Sprintf("\"%s\"\n-- *%s*", q.Text, author)
}

View file

@ -0,0 +1,66 @@
package runtime
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// HealthServer serves health and metrics endpoints.
type HealthServer struct {
runtime *Runtime
port int
}
// NewHealthServer creates a new health server.
func NewHealthServer(rt *Runtime, port int) *HealthServer {
return &HealthServer{runtime: rt, port: port}
}
// Start starts the HTTP health server.
func (h *HealthServer) Start() *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.handleHealth)
mux.HandleFunc("GET /metrics", h.handleMetrics)
server := &http.Server{
Addr: fmt.Sprintf(":%d", h.port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
go func() {
slog.Info("health server starting", "port", h.port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("health server error", "error", err)
}
}()
return server
}
func (h *HealthServer) handleHealth(w http.ResponseWriter, r *http.Request) {
plugins := h.runtime.ActivePlugins()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"service": "mana-matrix-bot",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"plugins": plugins,
"count": len(plugins),
})
}
func (h *HealthServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
plugins := h.runtime.ActivePlugins()
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP mana_matrix_bot_plugins_active Number of active plugins\n")
fmt.Fprintf(w, "# TYPE mana_matrix_bot_plugins_active gauge\n")
fmt.Fprintf(w, "mana_matrix_bot_plugins_active %d\n", len(plugins))
}

View file

@ -0,0 +1,387 @@
package runtime
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"strings"
"github.com/manacore/mana-matrix-bot/internal/config"
"github.com/manacore/mana-matrix-bot/internal/matrix"
"github.com/manacore/mana-matrix-bot/internal/plugin"
"github.com/manacore/mana-matrix-bot/internal/services"
"github.com/manacore/mana-matrix-bot/internal/session"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// BotInstance represents one active plugin with its Matrix client.
type BotInstance struct {
Plugin plugin.Plugin
Client *matrix.Client
Config plugin.PluginConfig
MautrixCli *mautrix.Client
UserID id.UserID
}
// Runtime manages all plugin lifecycles.
type Runtime struct {
cfg *config.Config
sessions plugin.SessionManager
auth *services.AuthClient
bots []*BotInstance
mu sync.RWMutex
}
// New creates a new Runtime.
func New(cfg *config.Config) *Runtime {
// Try Redis for sessions, fall back to in-memory
var sessions plugin.SessionManager
if cfg.RedisHost != "" {
redisStore, err := session.NewRedisStore(session.RedisConfig{
Host: cfg.RedisHost,
Port: cfg.RedisPort,
Password: cfg.RedisPassword,
})
if err != nil {
slog.Warn("redis unavailable, using in-memory sessions", "error", err)
sessions = session.NewMemoryStore()
} else {
sessions = redisStore
slog.Info("using redis session store")
}
} else {
sessions = session.NewMemoryStore()
}
var auth *services.AuthClient
if cfg.AuthURL != "" {
auth = services.NewAuthClient(cfg.AuthURL)
}
return &Runtime{
cfg: cfg,
sessions: sessions,
auth: auth,
}
}
// Start initializes all enabled plugins and starts their Matrix sync loops.
func (r *Runtime) Start(ctx context.Context) error {
factories := plugin.All()
slog.Info("registered plugin factories", "count", len(factories))
for name, factory := range factories {
pluginCfg, ok := r.cfg.Plugins[name]
if !ok || !pluginCfg.Enabled {
slog.Info("plugin disabled or not configured", "plugin", name)
continue
}
if pluginCfg.AccessToken == "" {
slog.Warn("plugin has no access token, skipping", "plugin", name)
continue
}
p := factory()
// Create Matrix client for this plugin
storagePath := fmt.Sprintf("%s/sync_%s.json", r.cfg.StoragePath, name)
client, err := matrix.NewClient(matrix.ClientConfig{
HomeserverURL: r.cfg.HomeserverURL,
AccessToken: pluginCfg.AccessToken,
StoragePath: storagePath,
PluginName: name,
})
if err != nil {
slog.Error("failed to create matrix client", "plugin", name, "error", err)
continue
}
// Authenticate
userID, err := client.Login(ctx)
if err != nil {
slog.Error("failed to authenticate", "plugin", name, "error", err)
continue
}
// Convert config
pCfg := plugin.PluginConfig{
Enabled: pluginCfg.Enabled,
AccessToken: pluginCfg.AccessToken,
AllowedRooms: pluginCfg.AllowedRooms,
BackendURL: pluginCfg.BackendURL,
Extra: pluginCfg.Extra,
}
// Initialize plugin
if err := p.Init(ctx, pCfg); err != nil {
slog.Error("failed to init plugin", "plugin", name, "error", err)
continue
}
bot := &BotInstance{
Plugin: p,
Client: client,
Config: pCfg,
MautrixCli: client.Inner(),
UserID: userID,
}
r.mu.Lock()
r.bots = append(r.bots, bot)
r.mu.Unlock()
// Start sync loop for this bot
go r.startSync(ctx, bot)
// Start scheduled tasks if plugin implements Scheduler
if sched, ok := p.(plugin.Scheduler); ok {
for _, task := range sched.ScheduledTasks() {
go r.runScheduledTask(ctx, name, task)
}
}
slog.Info("plugin started", "plugin", name, "user_id", userID)
}
r.mu.RLock()
count := len(r.bots)
r.mu.RUnlock()
slog.Info("all plugins started", "active", count)
return nil
}
// Stop gracefully shuts down all plugins.
func (r *Runtime) Stop() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, bot := range r.bots {
bot.MautrixCli.StopSync()
slog.Info("plugin stopped", "plugin", bot.Plugin.Name())
}
}
// ActivePlugins returns the names of all active plugins.
func (r *Runtime) ActivePlugins() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, len(r.bots))
for i, bot := range r.bots {
names[i] = bot.Plugin.Name()
}
return names
}
// startSync starts the Matrix /sync loop for a bot instance.
func (r *Runtime) startSync(ctx context.Context, bot *BotInstance) {
syncer := bot.MautrixCli.Syncer.(*mautrix.DefaultSyncer)
// Auto-join rooms on invite
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
if evt.GetStateKey() == bot.UserID.String() {
content, ok := evt.Content.Parsed.(*event.MemberEventContent)
if ok && content.Membership == event.MembershipInvite {
_, err := bot.MautrixCli.JoinRoomByID(ctx, evt.RoomID)
if err != nil {
slog.Error("failed to join room", "plugin", bot.Plugin.Name(), "room", evt.RoomID, "error", err)
} else {
slog.Info("joined room", "plugin", bot.Plugin.Name(), "room", evt.RoomID)
}
}
}
})
// Handle messages
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
r.handleEvent(ctx, bot, evt)
})
slog.Info("starting sync", "plugin", bot.Plugin.Name())
if err := bot.MautrixCli.SyncWithContext(ctx); err != nil && ctx.Err() == nil {
slog.Error("sync error", "plugin", bot.Plugin.Name(), "error", err)
}
}
// handleEvent routes an event to the appropriate plugin handler.
func (r *Runtime) handleEvent(ctx context.Context, bot *BotInstance, evt *event.Event) {
// Ignore own messages
if evt.Sender == bot.UserID {
return
}
// Ignore messages from other bots
if matrix.IsBot(evt.Sender.String()) {
return
}
// Ignore edit events
if matrix.IsEditEvent(evt) {
return
}
// Check room allow-list
roomID := evt.RoomID.String()
if len(bot.Config.AllowedRooms) > 0 {
allowed := false
for _, r := range bot.Config.AllowedRooms {
if r == roomID {
allowed = true
break
}
}
if !allowed {
return
}
}
// Build message context
mc := &plugin.MessageContext{
RoomID: roomID,
Sender: evt.Sender.String(),
EventID: evt.ID.String(),
Client: bot.Client,
Session: &plugin.SessionAccess{
UserID: evt.Sender.String(),
Manager: r.sessions,
},
}
pluginName := bot.Plugin.Name()
// Route by message type
if matrix.IsTextMessage(evt) {
mc.Body = matrix.GetMessageBody(evt)
if mc.Body == "" {
return
}
// Global commands: !login / !logout (handled before plugins)
if r.handleGlobalCommand(ctx, mc) {
return
}
if err := bot.Client.SetTyping(ctx, roomID, true); err != nil {
slog.Debug("failed to set typing", "error", err)
}
if err := bot.Plugin.HandleTextMessage(ctx, mc); err != nil {
slog.Error("plugin error", "plugin", pluginName, "error", err)
bot.Client.SetTyping(ctx, roomID, false)
bot.Client.SendReply(ctx, roomID, evt.ID.String(), "❌ Ein Fehler ist aufgetreten.")
return
}
bot.Client.SetTyping(ctx, roomID, false)
} else if matrix.IsAudioMessage(evt) {
audioHandler, ok := bot.Plugin.(plugin.AudioHandler)
if !ok {
return
}
mxcURL := matrix.GetMediaURL(evt)
if mxcURL == "" {
return
}
audioData, err := bot.Client.DownloadMedia(ctx, mxcURL)
if err != nil {
slog.Error("download audio failed", "plugin", pluginName, "error", err)
return
}
if err := bot.Client.SetTyping(ctx, roomID, true); err != nil {
slog.Debug("failed to set typing", "error", err)
}
if err := audioHandler.HandleAudioMessage(ctx, mc, audioData); err != nil {
slog.Error("audio handler error", "plugin", pluginName, "error", err)
bot.Client.SetTyping(ctx, roomID, false)
bot.Client.SendReply(ctx, roomID, evt.ID.String(), "❌ Sprachverarbeitung fehlgeschlagen.")
return
}
bot.Client.SetTyping(ctx, roomID, false)
} else if matrix.IsImageMessage(evt) {
imageHandler, ok := bot.Plugin.(plugin.ImageHandler)
if !ok {
return
}
if err := imageHandler.HandleImageMessage(ctx, mc); err != nil {
slog.Error("image handler error", "plugin", pluginName, "error", err)
}
}
}
// handleGlobalCommand intercepts !login and !logout before plugin routing.
// Returns true if the command was handled.
func (r *Runtime) handleGlobalCommand(ctx context.Context, mc *plugin.MessageContext) bool {
lower := strings.ToLower(mc.Body)
// !login email password
if strings.HasPrefix(lower, "!login ") || strings.HasPrefix(lower, "!anmelden ") {
parts := strings.Fields(mc.Body)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Verwendung:** `!login email passwort`")
return true
}
if r.auth == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Auth-Service nicht konfiguriert.")
return true
}
email := parts[1]
password := parts[2]
resp, err := r.auth.Login(ctx, email, password)
if err != nil {
slog.Debug("login failed", "email", email, "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Login fehlgeschlagen. Überprüfe E-Mail und Passwort.")
return true
}
expiresAt := services.TokenExpiresAt(resp)
r.sessions.SetToken(mc.Sender, resp.Token, expiresAt)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Angemeldet als **%s**", email))
return true
}
// !logout / !abmelden
if lower == "!logout" || lower == "!abmelden" {
r.sessions.SetToken(mc.Sender, "", time.Now().Add(-1*time.Hour))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Abgemeldet.")
return true
}
return false
}
// runScheduledTask runs a periodic task for a plugin.
func (r *Runtime) runScheduledTask(ctx context.Context, pluginName string, task plugin.ScheduledTask) {
slog.Info("scheduled task started", "plugin", pluginName, "task", task.Name, "interval", task.Interval)
ticker := time.NewTicker(task.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := task.Run(ctx); err != nil {
slog.Error("scheduled task failed", "plugin", pluginName, "task", task.Name, "error", err)
}
}
}
}

View file

@ -0,0 +1,52 @@
package services
import (
"context"
"fmt"
"time"
)
// AuthClient handles login/logout via mana-core-auth.
type AuthClient struct {
backend *BackendClient
}
// LoginResponse holds the auth service login response.
type LoginResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"` // seconds
}
// NewAuthClient creates a new auth service client.
func NewAuthClient(authURL string) *AuthClient {
return &AuthClient{backend: NewBackendClient(authURL)}
}
// Login authenticates a user and returns a JWT token.
func (c *AuthClient) Login(ctx context.Context, email, password string) (*LoginResponse, error) {
body := map[string]string{
"email": email,
"password": password,
}
var resp LoginResponse
if err := c.backend.Post(ctx, "/api/v1/auth/login", "", body, &resp); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
if resp.Token == "" {
return nil, fmt.Errorf("login: no token in response")
}
return &resp, nil
}
// TokenExpiresAt calculates the expiry time from the login response.
func TokenExpiresAt(resp *LoginResponse) time.Time {
if resp.ExpiresIn > 0 {
return time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
}
// Default: 24 hours
return time.Now().Add(24 * time.Hour)
}

View file

@ -0,0 +1,154 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// BackendClient is a generic HTTP client for calling NestJS backends.
type BackendClient struct {
baseURL string
httpClient *http.Client
}
// NewBackendClient creates a new backend client.
func NewBackendClient(baseURL string) *BackendClient {
return &BackendClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Get sends a GET request and decodes the JSON response.
func (c *BackendClient) Get(ctx context.Context, path string, token string, result any) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend GET %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend GET %s: %d %s", path, resp.StatusCode, string(body))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Post sends a POST request with a JSON body and decodes the response.
func (c *BackendClient) Post(ctx context.Context, path string, token string, body any, result any) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, reqBody)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend POST %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend POST %s: %d %s", path, resp.StatusCode, string(body))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Put sends a PUT request with a JSON body.
func (c *BackendClient) Put(ctx context.Context, path string, token string, body any, result any) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+path, reqBody)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend PUT %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend PUT %s: %d %s", path, resp.StatusCode, string(respBody))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Delete sends a DELETE request.
func (c *BackendClient) Delete(ctx context.Context, path string, token string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+path, nil)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend DELETE %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend DELETE %s: %d %s", path, resp.StatusCode, string(body))
}
return nil
}

View file

@ -0,0 +1,62 @@
package services
import (
"context"
"fmt"
"log/slog"
)
// CreditClient handles credit balance and consumption via mana-core-auth.
type CreditClient struct {
backend *BackendClient
}
// CreditBalance holds a user's credit balance.
type CreditBalance struct {
Balance float64 `json:"balance"`
Used float64 `json:"used"`
}
// NewCreditClient creates a new credit service client.
func NewCreditClient(authURL, serviceKey string) *CreditClient {
client := NewBackendClient(authURL)
// Service key is used for internal API calls
_ = serviceKey
return &CreditClient{backend: client}
}
// GetBalance returns the user's current credit balance.
func (c *CreditClient) GetBalance(ctx context.Context, token string) (*CreditBalance, error) {
var balance CreditBalance
if err := c.backend.Get(ctx, "/api/v1/credits/balance", token, &balance); err != nil {
return nil, fmt.Errorf("get balance: %w", err)
}
return &balance, nil
}
// Consume deducts credits for an operation.
func (c *CreditClient) Consume(ctx context.Context, token string, amount float64, description string) error {
body := map[string]any{
"amount": amount,
"description": description,
}
if err := c.backend.Post(ctx, "/api/v1/credits/use", token, body, nil); err != nil {
return fmt.Errorf("consume credits: %w", err)
}
return nil
}
// HasEnough checks if the user has enough credits for an operation.
func (c *CreditClient) HasEnough(ctx context.Context, token string, required float64) (bool, float64) {
balance, err := c.GetBalance(ctx, token)
if err != nil {
slog.Debug("credit check failed", "error", err)
return true, 0 // Allow on error (fail open)
}
return balance.Balance >= required, balance.Balance
}
// FormatBalance returns a formatted credit balance string.
func FormatBalance(balance float64) string {
return fmt.Sprintf("%.2f", balance)
}

View file

@ -0,0 +1,118 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
)
// VoiceClient handles STT and TTS requests to the Python services.
type VoiceClient struct {
sttURL string
ttsURL string
httpClient *http.Client
}
// TranscriptionResult holds the STT response.
type TranscriptionResult struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
}
// NewVoiceClient creates a new voice service client.
func NewVoiceClient(sttURL, ttsURL string) *VoiceClient {
return &VoiceClient{
sttURL: sttURL,
ttsURL: ttsURL,
httpClient: &http.Client{
Timeout: 120 * time.Second, // STT/TTS can be slow
},
}
}
// Transcribe sends audio data to the STT service and returns the transcription.
func (c *VoiceClient) Transcribe(ctx context.Context, audioData []byte, language string) (*TranscriptionResult, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "audio.ogg")
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(audioData); err != nil {
return nil, fmt.Errorf("write audio: %w", err)
}
if language != "" {
if err := writer.WriteField("language", language); err != nil {
return nil, fmt.Errorf("write language: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.sttURL+"/transcribe", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("STT request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("STT error %d: %s", resp.StatusCode, string(body))
}
var result TranscriptionResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode STT response: %w", err)
}
return &result, nil
}
// Synthesize sends text to the TTS service and returns audio data.
func (c *VoiceClient) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
payload := map[string]any{
"text": text,
}
if voice != "" {
payload["voice"] = voice
}
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.ttsURL+"/synthesize", bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("TTS request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("TTS error %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}

View file

@ -0,0 +1,174 @@
package session
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
// RedisStore is a Redis-backed session manager.
// Sessions are shared across all plugins and persist across restarts.
type RedisStore struct {
client *redis.Client
prefix string
ttl time.Duration
}
// RedisConfig holds configuration for the Redis session store.
type RedisConfig struct {
Host string
Port int
Password string
Prefix string // key prefix (default: "mana-bot:session:")
TTL time.Duration // session TTL (default: 24h)
}
// NewRedisStore creates a new Redis session store.
func NewRedisStore(cfg RedisConfig) (*RedisStore, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis ping: %w", err)
}
prefix := cfg.Prefix
if prefix == "" {
prefix = "mana-bot:session:"
}
ttl := cfg.TTL
if ttl == 0 {
ttl = 24 * time.Hour
}
slog.Info("redis session store connected", "addr", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
return &RedisStore{
client: client,
prefix: prefix,
ttl: ttl,
}, nil
}
// key builds the Redis key for a user's session data.
func (s *RedisStore) key(userID string) string {
return s.prefix + userID
}
// tokenKey builds the Redis key for a user's auth token.
func (s *RedisStore) tokenKey(userID string) string {
return s.prefix + "token:" + userID
}
// Get retrieves a session value.
func (s *RedisStore) Get(userID, key string) (any, bool) {
ctx := context.Background()
val, err := s.client.HGet(ctx, s.key(userID), key).Result()
if err != nil {
return nil, false
}
// Try to unmarshal as JSON first
var result any
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result, true
}
return val, true
}
// Set stores a session value.
func (s *RedisStore) Set(userID, key string, value any) {
ctx := context.Background()
data, err := json.Marshal(value)
if err != nil {
slog.Error("redis session set: marshal failed", "error", err)
return
}
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.key(userID), key, string(data))
pipe.Expire(ctx, s.key(userID), s.ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis session set failed", "error", err)
}
}
// Delete removes a session value.
func (s *RedisStore) Delete(userID, key string) {
ctx := context.Background()
if err := s.client.HDel(ctx, s.key(userID), key).Err(); err != nil {
slog.Error("redis session delete failed", "error", err)
}
}
// GetToken returns the stored auth token for a user.
func (s *RedisStore) GetToken(userID string) (string, bool) {
ctx := context.Background()
// Get token and expiry from hash
vals, err := s.client.HMGet(ctx, s.tokenKey(userID), "token", "expires_at").Result()
if err != nil || len(vals) < 2 || vals[0] == nil {
return "", false
}
token, ok := vals[0].(string)
if !ok || token == "" {
return "", false
}
// Check expiry
if expiresStr, ok := vals[1].(string); ok && expiresStr != "" {
expiresAt, err := time.Parse(time.RFC3339, expiresStr)
if err == nil && time.Now().After(expiresAt) {
// Token expired, clean up
s.client.Del(ctx, s.tokenKey(userID))
return "", false
}
}
return token, true
}
// SetToken stores an auth token with expiration.
func (s *RedisStore) SetToken(userID, token string, expiresAt time.Time) {
ctx := context.Background()
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.tokenKey(userID), map[string]any{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
})
// Set Redis TTL to match token expiry (plus buffer)
ttl := time.Until(expiresAt) + 1*time.Hour
if ttl < s.ttl {
ttl = s.ttl
}
pipe.Expire(ctx, s.tokenKey(userID), ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis set token failed", "error", err)
}
}
// IsLoggedIn checks if a user has a valid token.
func (s *RedisStore) IsLoggedIn(userID string) bool {
_, ok := s.GetToken(userID)
return ok
}
// Close closes the Redis connection.
func (s *RedisStore) Close() error {
return s.client.Close()
}

View file

@ -0,0 +1,93 @@
package session
import (
"sync"
"time"
)
// UserSession holds per-user session data.
type UserSession struct {
Token string
ExpiresAt time.Time
Data map[string]any
}
// MemoryStore is an in-memory session manager.
type MemoryStore struct {
mu sync.RWMutex
sessions map[string]*UserSession // keyed by userID
}
// NewMemoryStore creates a new in-memory session store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
sessions: make(map[string]*UserSession),
}
}
func (s *MemoryStore) getOrCreate(userID string) *UserSession {
if sess, ok := s.sessions[userID]; ok {
return sess
}
sess := &UserSession{Data: make(map[string]any)}
s.sessions[userID] = sess
return sess
}
// Get retrieves a session value.
func (s *MemoryStore) Get(userID, key string) (any, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[userID]
if !ok {
return nil, false
}
val, ok := sess.Data[key]
return val, ok
}
// Set stores a session value.
func (s *MemoryStore) Set(userID, key string, value any) {
s.mu.Lock()
defer s.mu.Unlock()
sess := s.getOrCreate(userID)
sess.Data[key] = value
}
// Delete removes a session value.
func (s *MemoryStore) Delete(userID, key string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[userID]; ok {
delete(sess.Data, key)
}
}
// GetToken returns the stored auth token for a user.
func (s *MemoryStore) GetToken(userID string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[userID]
if !ok || sess.Token == "" {
return "", false
}
if time.Now().After(sess.ExpiresAt) {
return "", false
}
return sess.Token, true
}
// SetToken stores an auth token with expiration.
func (s *MemoryStore) SetToken(userID, token string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
sess := s.getOrCreate(userID)
sess.Token = token
sess.ExpiresAt = expiresAt
}
// IsLoggedIn checks if a user has a valid (non-expired) token.
func (s *MemoryStore) IsLoggedIn(userID string) bool {
_, ok := s.GetToken(userID)
return ok
}

View file

@ -0,0 +1,12 @@
{
"name": "mana-matrix-bot",
"version": "1.0.0",
"private": true,
"description": "Consolidated Go Matrix bot replacing 21 NestJS bot services",
"scripts": {
"build": "go build -ldflags=\"-s -w\" -o dist/mana-matrix-bot ./cmd/server",
"dev": "go run ./cmd/server",
"test": "go test ./...",
"docker:build": "docker build -t mana-matrix-bot:local -f Dockerfile ../../"
}
}

View file

@ -1,6 +0,0 @@
node_modules
dist
.git
*.log
.env*
data

View file

@ -1,167 +0,0 @@
# Matrix Calendar Bot - Claude Code Guidelines
## Overview
Matrix Calendar Bot provides calendar/event management via Matrix chat. It integrates with the Calendar backend for full CRUD operations, syncing events across Matrix, web, and mobile apps.
**Login Required**: Users must login (`!login email password`) to use the bot. All events are synchronized with the calendar-backend.
## Tech Stack
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Backend**: Calendar API (port 3014)
- **Auth**: Mana Core Auth (JWT)
## Commands
```bash
# Development
pnpm install
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/matrix-calendar-bot/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & help texts
│ └── bot/
│ ├── bot.module.ts
│ └── matrix.service.ts # Matrix client & command handlers
├── Dockerfile
└── package.json
```
## Matrix Commands
| Command | Description |
|---------|-------------|
| `!help` | Show help message |
| `!login email pass` | Login (required before use) |
| `!logout` | Logout |
| `!heute` / `!today` | Show today's events |
| `!morgen` / `!tomorrow` | Show tomorrow's events |
| `!woche` / `!week` | Show this week's events |
| `!termine` | Show next 14 days |
| `!termin [...]` | Create new event |
| `!details [nr]` | Show event details |
| `!löschen [nr]` | Delete event |
| `!kalender` | Show calendars |
| `!status` | Bot status |
| `!pin` | Pin help to room |
## Natural Language Keywords
The bot also responds to natural language (German + English):
- "hilfe", "help" → Show help
- "was steht heute an", "termine heute" → Today's events
- "termine morgen" → Tomorrow's events
- "diese woche", "wochenübersicht" → Week events
- "zeige kalender" → Show calendars
## Event Input Syntax
```
!termin Meeting am 15.02. um 14:00
│ │ └── Time (optional, defaults to 9:00)
│ └── Date (DD.MM. or DD.MM.YYYY)
└── Event title
!termin Zahnarzt morgen um 10:30
│ └── Time
└── Relative date (heute, morgen, übermorgen)
!termin Geburtstag am 20.03. ganztägig
└── All-day event flag
```
## Environment Variables
```env
# Server
PORT=3315
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#calendar-bot:mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Calendar Backend
CALENDAR_BACKEND_URL=http://localhost:3014
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# Redis (for session storage)
REDIS_URL=redis://localhost:6379
# Speech-to-Text (optional)
STT_URL=http://localhost:3020
```
## Docker
```bash
# Build locally
docker build -f services/matrix-calendar-bot/Dockerfile -t matrix-calendar-bot services/matrix-calendar-bot
# Run
docker run -p 3315:3315 \
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e CALENDAR_BACKEND_URL=http://calendar-backend:3014 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-v matrix-calendar-bot-data:/app/data \
matrix-calendar-bot
```
## Health Check
```bash
curl http://localhost:3315/health
```
## Getting a Matrix Access Token
```bash
# Login to get access token
curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d '{
"type": "m.login.password",
"user": "calendar-bot",
"password": "your-password"
}'
# Response contains: {"access_token": "syt_xxx", ...}
```
## Authentication Flow
1. User sends `!login email password`
2. Bot authenticates via mana-core-auth
3. JWT token stored in Redis session
4. Token used for all Calendar API calls
5. Events sync with calendar-backend (PostgreSQL)
## Data Synchronization
All events are stored in the Calendar backend PostgreSQL database. Changes made via:
- Matrix bot
- Calendar web app
- Calendar mobile app
...are all synchronized automatically.

View file

@ -1,72 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-slim AS builder
WORKDIR /app
# Enable pnpm via corepack
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy shared packages that this bot depends on
COPY packages/bot-services ./packages/bot-services
COPY packages/matrix-bot-common ./packages/matrix-bot-common
# Copy this bot
COPY services/matrix-calendar-bot ./services/matrix-calendar-bot
# Install all dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
RUN pnpm --filter @manacore/bot-services build
RUN pnpm --filter @manacore/matrix-bot-common build
# Build the bot
RUN pnpm --filter @manacore/matrix-calendar-bot build
# Production stage
FROM node:20-slim AS runner
WORKDIR /app
# Install wget for health checks and enable pnpm
RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy built shared packages
COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist
COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/
COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist
COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/
# Copy built bot
COPY --from=builder /app/services/matrix-calendar-bot/dist ./services/matrix-calendar-bot/dist
COPY --from=builder /app/services/matrix-calendar-bot/package.json ./services/matrix-calendar-bot/
# Install production dependencies only
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts
# Create data directory
RUN mkdir -p /app/data
# Create non-root user
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 -g nodejs nestjs && \
chown -R nestjs:nodejs /app/data
USER nestjs
WORKDIR /app/services/matrix-calendar-bot
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4015/health || exit 1
EXPOSE 4015
CMD ["node", "dist/main.js"]

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,46 +0,0 @@
{
"name": "@manacore/matrix-calendar-bot",
"version": "1.0.0",
"description": "Matrix bot for calendar management - GDPR compliant",
"private": true,
"license": "MIT",
"pnpm": {
"neverBuiltDependencies": [
"@matrix-org/matrix-sdk-crypto-nodejs"
],
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
}
},
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
},
"scripts": {
"prebuild": "rm -rf dist || true",
"build": "tsc -p tsconfig.build.json",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/bot-services": "workspace:*",
"@manacore/matrix-bot-common": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"matrix-bot-sdk": "^0.7.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View file

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
],
controllers: [HealthController],
providers: [createHealthProvider('matrix-calendar-bot')],
})
export class AppModule {}

View file

@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MatrixService } from './matrix.service';
import {
TranscriptionModule,
SessionModule,
CreditModule,
CalendarApiService,
I18nModule,
} from '@manacore/bot-services';
// Factory provider for CalendarApiService
const calendarApiServiceProvider = {
provide: CalendarApiService,
useFactory: (configService: ConfigService) => {
const baseUrl = configService.get<string>('CALENDAR_BACKEND_URL', 'http://localhost:3014');
return new CalendarApiService(baseUrl);
},
inject: [ConfigService],
};
@Module({
imports: [
ConfigModule,
TranscriptionModule.register({
sttUrl: process.env.STT_URL || 'http://localhost:3020',
}),
SessionModule.forRoot({ storageMode: 'redis' }),
CreditModule.forRoot(),
I18nModule.forRoot(),
],
providers: [MatrixService, calendarApiServiceProvider],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -1,686 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
KeywordCommandDetector,
COMMON_KEYWORDS,
} from '@manacore/matrix-bot-common';
import {
TranscriptionService,
SessionService,
CreditService,
CalendarApiService,
CalendarEvent as ApiCalendarEvent,
I18nService,
Language,
LANGUAGE_NAMES,
} from '@manacore/bot-services';
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
const EVENT_CREATE_CREDITS = 0.02;
// Alias for consistency
type CalendarEvent = ApiCalendarEvent;
@Injectable()
export class MatrixService extends BaseMatrixService {
private readonly keywordDetector = new KeywordCommandDetector(
[
...COMMON_KEYWORDS,
{ keywords: ['was kannst du'], command: 'help' },
{
keywords: ['was steht heute an', 'termine heute', 'heute termine', "today's events"],
command: 'today',
},
{ keywords: ['termine morgen', 'morgen termine', 'was ist morgen'], command: 'tomorrow' },
{ keywords: ['diese woche', 'wochenübersicht', 'week', 'woche'], command: 'week' },
{ keywords: ['zeige kalender', 'meine kalender', 'calendars'], command: 'calendars' },
{ keywords: ['verbindung', 'connection'], command: 'status' },
],
{ partialMatch: true }
);
constructor(
configService: ConfigService,
private readonly transcriptionService: TranscriptionService,
private calendarApiService: CalendarApiService,
private sessionService: SessionService,
private creditService: CreditService,
private i18nService: I18nService
) {
super(configService);
}
/**
* Check if user is logged in and has a valid token for API access
*/
private async getToken(userId: string): Promise<string | null> {
return this.sessionService.getToken(userId);
}
/**
* Require login - returns token or sends login prompt and returns null
*/
private async requireLogin(
roomId: string,
event: MatrixRoomEvent,
userId: string
): Promise<string | null> {
const token = await this.getToken(userId);
if (!token) {
await this.sendReply(
roomId,
event,
'🔐 **Login erforderlich**\n\n' +
'Um Termine zu verwalten, melde dich bitte an:\n\n' +
'`!login deine@email.de deinpasswort`\n\n' +
'Deine Termine werden dann mit der Kalender-App synchronisiert.'
);
return null;
}
return token;
}
/**
* Normalize event from API format
*/
private normalizeEvent(event: ApiCalendarEvent): CalendarEvent {
return {
id: event.id,
title: event.title,
description: event.description || null,
location: event.location || null,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
calendarId: event.calendarId || '',
calendarName: 'Kalender',
createdAt: event.createdAt || new Date().toISOString(),
userId: event.userId || '',
};
}
protected override async handleAudioMessage(
roomId: string,
event: MatrixRoomEvent,
sender: string
): Promise<void> {
try {
// Require login for audio messages
const token = await this.requireLogin(roomId, event, sender);
if (!token) return;
const mxcUrl = event.content.url;
if (!mxcUrl) return;
const audioBuffer = await this.downloadMedia(mxcUrl);
const text = await this.transcriptionService.transcribe(audioBuffer);
if (!text) {
await this.sendReply(roomId, event, '❌ Sprachnachricht konnte nicht erkannt werden.');
return;
}
await this.sendMessage(roomId, `🎤 *"${text}"*`);
await this.handleTextMessage(roomId, event, text, sender);
} catch (error) {
this.logger.error(`Audio transcription error: ${error}`);
await this.sendReply(roomId, event, '❌ Fehler bei der Spracherkennung.');
}
}
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl:
this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
protected getIntroductionMessage(): string | null {
return BOT_INTRODUCTION;
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
// Check for ! commands first (before keyword detection)
if (message.startsWith('!')) {
const [command, ...args] = message.slice(1).split(' ');
await this.executeCommand(roomId, event, sender, command.toLowerCase(), args.join(' '));
return;
}
// Check for natural language keywords
const keywordCommand = this.keywordDetector.detect(message);
if (keywordCommand) {
await this.executeCommand(roomId, event, sender, keywordCommand, '');
return;
}
// Fallback: treat any message as an event
await this.handleCreateEvent(roomId, event, sender, message);
}
private async executeCommand(
roomId: string,
event: MatrixRoomEvent,
userId: string,
command: string,
args: string
) {
switch (command) {
case 'help':
case 'hilfe':
await this.sendReply(roomId, event, HELP_TEXT);
break;
case 'heute':
case 'today':
await this.handleTodayEvents(roomId, event, userId);
break;
case 'morgen':
case 'tomorrow':
await this.handleTomorrowEvents(roomId, event, userId);
break;
case 'woche':
case 'week':
await this.handleWeekEvents(roomId, event, userId);
break;
case 'termine':
case 'events':
case 'upcoming':
await this.handleUpcomingEvents(roomId, event, userId);
break;
case 'termin':
case 'event':
case 'neu':
case 'add':
await this.handleCreateEvent(roomId, event, userId, args);
break;
case 'details':
case 'info':
await this.handleEventDetails(roomId, event, userId, args);
break;
case 'löschen':
case 'delete':
case 'entfernen':
await this.handleDeleteEvent(roomId, event, userId, args);
break;
case 'kalender':
case 'calendars':
await this.handleCalendars(roomId, event, userId);
break;
case 'status':
await this.handleStatus(roomId, event, userId);
break;
case 'pin':
await this.handlePinHelp(roomId, event);
break;
case 'language':
case 'sprache':
case 'lang':
await this.handleLanguage(roomId, event, userId, args);
break;
default:
// Unknown command - ignore silently
break;
}
}
private async handleTodayEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
const apiEvents = await this.calendarApiService.getTodayEvents(token);
const events = apiEvents.map((e) => this.normalizeEvent(e));
if (events.length === 0) {
await this.sendReply(
roomId,
event,
'📭 Keine Termine für heute.\n\nErstelle einen mit `!termin Titel heute um 14:00`'
);
return;
}
let response = this.formatEventList('📅 **Termine heute:**', events);
response += '\n\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleTomorrowEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
// Get events for tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split('T')[0];
const apiEvents = await this.calendarApiService.getEvents(token, {
start: tomorrowStr,
end: tomorrowStr,
});
const events = apiEvents.map((e) => this.normalizeEvent(e));
if (events.length === 0) {
await this.sendReply(
roomId,
event,
'📭 Keine Termine für morgen.\n\nErstelle einen mit `!termin Titel morgen um 10:00`'
);
return;
}
let response = this.formatEventList('📅 **Termine morgen:**', events);
response += '\n\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleWeekEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
const events = apiEvents.map((e) => this.normalizeEvent(e));
if (events.length === 0) {
await this.sendReply(
roomId,
event,
'📭 Keine Termine diese Woche.\n\nErstelle einen mit `!termin Titel am 20.02. um 14:00`'
);
return;
}
let response = this.formatEventList('📅 **Termine diese Woche:**', events);
response += '\n\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleUpcomingEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 14);
const events = apiEvents.map((e) => this.normalizeEvent(e));
if (events.length === 0) {
await this.sendReply(
roomId,
event,
'📭 Keine anstehenden Termine.\n\nErstelle einen mit `!termin Meeting am 15.02. um 14:00`'
);
return;
}
let response = this.formatEventList('📅 **Anstehende Termine:**', events);
response += '\n\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleCreateEvent(
roomId: string,
event: MatrixRoomEvent,
userId: string,
input: string
) {
if (!input.trim()) {
await this.sendReply(
roomId,
event,
'❌ Bitte gib einen Termin an.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Geburtstag am 20.03. ganztägig`\n• `!termin Zahnarzt am 15.02. um 10:30`'
);
return;
}
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
// Validate credits
const validation = await this.creditService.validateCredits(token, EVENT_CREATE_CREDITS);
if (!validation.hasCredits) {
const errorMsg = this.creditService.formatInsufficientCreditsError(
EVENT_CREATE_CREDITS,
validation.availableCredits,
'Termin erstellen'
);
await this.sendReply(roomId, event, errorMsg.text);
return;
}
// Use API service
const { title, startTime, endTime, isAllDay, location } =
this.calendarApiService.parseEventInput(input);
if (!startTime || !endTime) {
await this.sendReply(
roomId,
event,
'❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`'
);
return;
}
if (!title) {
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
return;
}
const apiEvent = await this.calendarApiService.createEvent(token, {
title,
startTime,
endTime,
isAllDay,
location: location || undefined,
});
if (!apiEvent) {
await this.sendReply(
roomId,
event,
'❌ Fehler beim Erstellen des Termins. Bitte versuche es erneut.'
);
return;
}
const calendarEvent = this.normalizeEvent(apiEvent);
const timeStr = this.formatEventTime(calendarEvent);
let response = `✅ Termin erstellt: **${calendarEvent.title}**\n📆 ${timeStr}`;
const balance = await this.creditService.getBalance(token);
response += `\n⚡ -${EVENT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
response += '\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleEventDetails(
roomId: string,
event: MatrixRoomEvent,
userId: string,
args: string
) {
const eventNumber = parseInt(args.trim());
if (isNaN(eventNumber) || eventNumber < 1) {
await this.sendReply(
roomId,
event,
'❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!details 1`'
);
return;
}
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
let calendarEvent: CalendarEvent | null = null;
// Use API service - get event list first
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
calendarEvent = this.normalizeEvent(apiEvents[eventNumber - 1]);
}
if (!calendarEvent) {
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
return;
}
const timeStr = this.formatEventTime(calendarEvent);
let response = `📅 **${calendarEvent.title}**\n\n`;
response += `🕐 ${timeStr}\n`;
response += `📁 Kalender: ${calendarEvent.calendarName}\n`;
if (calendarEvent.location) {
response += `📍 Ort: ${calendarEvent.location}\n`;
}
if (calendarEvent.description) {
response += `\n📝 ${calendarEvent.description}`;
}
response += '\n\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleDeleteEvent(
roomId: string,
event: MatrixRoomEvent,
userId: string,
args: string
) {
const eventNumber = parseInt(args.trim());
if (isNaN(eventNumber) || eventNumber < 1) {
await this.sendReply(
roomId,
event,
'❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!löschen 1`'
);
return;
}
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
let deletedEvent: CalendarEvent | null = null;
// Use API service - get event list first to find event by index
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
const targetEvent = apiEvents[eventNumber - 1];
const success = await this.calendarApiService.deleteEvent(token, targetEvent.id);
if (success) {
deletedEvent = this.normalizeEvent(targetEvent);
}
}
if (!deletedEvent) {
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
return;
}
const response = `🗑️ Gelöscht: ${deletedEvent.title}\n\n🔄 Synchronisiert`;
await this.sendReply(roomId, event, response);
}
private async handleCalendars(roomId: string, event: MatrixRoomEvent, userId: string) {
// Require login
const token = await this.requireLogin(roomId, event, userId);
if (!token) return;
const calendars = await this.calendarApiService.getCalendars(token);
let response = '📁 **Deine Kalender:**\n\n';
for (const calendar of calendars) {
response += `${calendar.name}\n`;
}
response += '\n🔄 Synchronisiert';
await this.sendReply(roomId, event, response);
}
private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) {
const token = await this.getToken(userId);
const session = await this.sessionService.getSession(userId);
let response = `📊 **Status**\n\n`;
if (token && session) {
// Get stats from API
const apiTodayEvents = await this.calendarApiService.getTodayEvents(token);
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
const todayEvents = apiTodayEvents.map((e) => this.normalizeEvent(e));
const events = apiEvents.map((e) => this.normalizeEvent(e));
response += `• Termine heute: ${todayEvents.length}\n`;
response += `• Termine nächste 7 Tage: ${events.length}\n\n`;
const balance = await this.creditService.getBalance(token);
response += `👤 Angemeldet als: ${session.email}\n`;
response += `⚡ Credits: ${balance.balance.toFixed(2)}\n\n`;
response += `🔄 Synchronisiert mit calendar-backend\n`;
response += `Bot: ✅ Online`;
} else {
response += `👤 Nicht angemeldet\n\n`;
response += `🔐 **Login erforderlich**\n\n`;
response += `Um Termine zu verwalten, melde dich an:\n`;
response += `\`!login deine@email.de deinpasswort\`\n\n`;
response += `Deine Termine werden dann mit der Kalender-App synchronisiert.\n\n`;
response += `Bot: ✅ Online`;
}
await this.sendReply(roomId, event, response);
}
private async handlePinHelp(roomId: string, event: MatrixRoomEvent) {
try {
// Send help message
const helpEventId = await this.sendMessage(roomId, HELP_TEXT);
// Pin it
await this.getClient().sendStateEvent(roomId, 'm.room.pinned_events', '', {
pinned: [helpEventId],
});
await this.sendReply(roomId, event, '📌 Hilfe wurde angepinnt!');
} catch (error) {
this.logger.error('Failed to pin help:', error);
await this.sendReply(
roomId,
event,
'❌ Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'
);
}
}
private formatEventList(header: string, events: CalendarEvent[]): string {
let response = `${header}\n\n`;
events.forEach((event, index) => {
const num = index + 1;
const timeStr = this.formatEventTime(event);
response += `**${num}.** ${event.title}\n 🕐 ${timeStr}\n`;
});
response += `\n📋 Details: \`!details [Nr]\` | 🗑️ Löschen: \`!löschen [Nr]\``;
return response;
}
/**
* Format event time for display
*/
private formatEventTime(event: CalendarEvent): string {
const start = new Date(event.startTime);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Check if date is today or tomorrow
let dateStr: string;
if (start.toDateString() === today.toDateString()) {
dateStr = 'Heute';
} else if (start.toDateString() === tomorrow.toDateString()) {
dateStr = 'Morgen';
} else {
dateStr = start.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
}
if (event.isAllDay) {
return `${dateStr} (ganztägig)`;
}
const timeStr = start.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
return `${dateStr}, ${timeStr}`;
}
// Public method to send welcome message to new users
async sendWelcomeMessage(roomId: string, userId: string) {
try {
await this.sendMessage(roomId, WELCOME_TEXT);
this.logger.log(`Sent welcome message to ${userId} in ${roomId}`);
} catch (error) {
this.logger.error(`Failed to send welcome message: ${error}`);
}
}
private async handleLanguage(
roomId: string,
event: MatrixRoomEvent,
userId: string,
args: string
) {
const lang = args.trim().toLowerCase();
if (!lang) {
const currentLang = await this.i18nService.getLanguage(userId);
const langName = LANGUAGE_NAMES[currentLang];
const available = this.i18nService
.getAvailableLanguages()
.map((l) => `${l} (${LANGUAGE_NAMES[l]})`)
.join(', ');
await this.sendReply(
roomId,
event,
`**Sprache / Language:** ${langName}\n\n**Verfügbar / Available:** ${available}\n\nÄndern / Change: \`!language de\` oder / or \`!language en\``
);
return;
}
if (!this.i18nService.isValidLanguage(lang)) {
const available = this.i18nService.getAvailableLanguages().join(', ');
await this.sendReply(
roomId,
event,
`Unbekannte Sprache / Unknown language: ${lang}\n\nVerfügbar / Available: ${available}`
);
return;
}
await this.i18nService.setLanguage(userId, lang as Language);
const langName = LANGUAGE_NAMES[lang as Language];
if (lang === 'de') {
await this.sendReply(roomId, event, `Sprache geändert zu: **${langName}**`);
} else {
await this.sendReply(roomId, event, `Language changed to: **${langName}**`);
}
}
}

View file

@ -1,61 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3315', 10),
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN || '',
allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean),
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
calendar: {
apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3016/api/v1',
},
});
export const HELP_TEXT = `📅 **Calendar Bot - Hilfe**
**Termine anzeigen:**
\`!heute\` - Termine für heute
\`!morgen\` - Termine für morgen
\`!woche\` - Termine diese Woche
\`!termine\` - Nächste 7 Tage
**Termine erstellen:**
\`!termin [Titel] am [Datum] um [Uhrzeit]\`
\`!termin Meeting am 15.02. um 14:00\`
\`!termin Zahnarzt morgen um 10:30\`
\`!termin Geburtstag am 20.03. ganztägig\`
**Termine verwalten:**
\`!details [Nr]\` - Details zu einem Termin
\`!löschen [Nr]\` - Termin löschen
**Kalender:**
\`!kalender\` - Deine Kalender anzeigen
**Sonstiges:**
\`!status\` - Verbindungsstatus
\`!help\` oder \`hilfe\` - Diese Hilfe
**Natürliche Sprache:**
Du kannst auch "was steht heute an?", "termine morgen" oder "zeige kalender" schreiben.`;
export const WELCOME_TEXT = `👋 **Willkommen beim Calendar Bot!**
Ich helfe dir, deine Termine zu verwalten. Hier sind die wichtigsten Befehle:
\`!heute\` - Heutige Termine
\`!termin Meeting morgen um 14:00\` - Termin erstellen
\`!woche\` - Wochenübersicht
Schreibe \`!help\` oder einfach "hilfe" für alle Befehle.`;
export const BOT_INTRODUCTION = `📅 **Hallo! Ich bin der Calendar Bot.**
Ich bin jetzt diesem Raum beigetreten und kann dir bei der Terminverwaltung helfen.
**Schnellstart:**
\`!heute\` - Was steht heute an?
\`!termin Arzt morgen um 10:00\` - Termin erstellen
\`!woche\` - Wochenübersicht
Schreibe \`!help\` für alle Befehle!`;

View file

@ -1,17 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port', 3315);
await app.listen(port);
logger.log(`Calendar Bot is running on port ${port}`);
}
bootstrap();

View file

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
}
}

View file

@ -1,15 +0,0 @@
# Server
PORT=3327
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#chat:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Chat Backend
CHAT_BACKEND_URL=http://localhost:3002
CHAT_API_PREFIX=
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001

View file

@ -1,5 +0,0 @@
node_modules/
dist/
.env
data/
*.log

View file

@ -1,226 +0,0 @@
# Matrix Chat Bot - Claude Code Guidelines
## Overview
Matrix Chat Bot provides AI chat capabilities via Matrix chat. It integrates with the Chat backend for conversations, AI completions, and message history.
## Tech Stack
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Backend**: Chat API (port 3002)
- **Auth**: Mana Core Auth (JWT)
## Commands
```bash
# Development
pnpm install
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/matrix-chat-bot/
├── src/
│ ├── main.ts # Application entry point (port 3327)
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & help messages
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── matrix.service.ts # Matrix client & command handlers
│ ├── chat/
│ │ ├── chat.module.ts
│ │ └── chat.service.ts # Chat Backend API client
│ └── session/
│ ├── session.module.ts
│ └── session.service.ts # User session & auth management
├── Dockerfile
└── package.json
```
## Bot Commands
| Command | Aliases | Description |
|---------|---------|-------------|
| `!help` | hilfe | Show help message |
| `!login email pass` | - | Login |
| `!logout` | - | Logout |
| `!status` | - | Bot status |
### Quick Chat (Stateless)
| Command | Aliases | Description |
|---------|---------|-------------|
| `!chat [message]` | fragen, ask | Quick AI response (no history) |
### Conversation Management
| Command | Aliases | Description |
|---------|---------|-------------|
| `!neu [titel]` | new | Create new conversation |
| `!gespraeche` | conversations, liste | List all conversations |
| `!gespraech [nr]` | conversation, select | Select/view conversation |
| `!senden [message]` | send, s | Send message in current conversation |
| `!verlauf` | history, nachrichten | Show message history |
### Conversation Actions
| Command | Aliases | Description |
|---------|---------|-------------|
| `!titel [nr] [title]` | title | Change conversation title |
| `!archiv [nr]` | archive | Archive conversation |
| `!archiviert` | archived | List archived conversations |
| `!wiederherstellen [nr]` | restore, unarchive | Restore from archive |
| `!pin [nr]` | - | Pin conversation |
| `!unpin [nr]` | - | Unpin conversation |
| `!loeschen [nr]` | delete | Delete conversation |
### Model Selection
| Command | Aliases | Description |
|---------|---------|-------------|
| `!modelle` | models | List available AI models |
| `!modell [nr]` | model | Select model for new conversations |
## Model Providers
| Provider | Icon | Description |
|----------|------|-------------|
| `ollama` | 🏠 | Local models (self-hosted) |
| `openrouter` | ☁️ | Cloud models via OpenRouter |
| `openai` | 🤖 | OpenAI models |
| `anthropic` | 🧠 | Anthropic Claude models |
## Example Usage
```
# Login
!login max@example.com mypassword
# Quick chat (no conversation needed)
!chat Was ist die Hauptstadt von Frankreich?
# Create a conversation
!neu Programmierung Hilfe
# Send message in conversation
!senden Erklaere mir Python Listen
# View message history
!verlauf
# List conversations
!gespraeche
# Select conversation
!gespraech 1
# Change model
!modelle
!modell 2
# Archive and restore
!archiv 1
!archiviert
!wiederherstellen 1
# Pin conversation
!pin 1
!unpin 1
# Delete conversation
!loeschen 1
```
## Environment Variables
```env
# Server
PORT=3327
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#chat:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Chat Backend
CHAT_BACKEND_URL=http://localhost:3002
CHAT_API_PREFIX=
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
```
## Docker
```bash
# Build locally
docker build -f services/matrix-chat-bot/Dockerfile -t matrix-chat-bot services/matrix-chat-bot
# Run
docker run -p 3327:3327 \
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e CHAT_BACKEND_URL=http://chat-backend:3002 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-v matrix-chat-bot-data:/app/data \
matrix-chat-bot
```
## Health Check
```bash
curl http://localhost:3327/health
```
## Chat Backend API Endpoints Used
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/models` | GET | List AI models (public) |
| `/models/:id` | GET | Get model details (public) |
| `/chat/completions` | POST | Create AI completion |
| `/conversations` | GET | List conversations |
| `/conversations` | POST | Create conversation |
| `/conversations/archived` | GET | List archived |
| `/conversations/:id` | GET | Get conversation |
| `/conversations/:id` | DELETE | Delete conversation |
| `/conversations/:id/messages` | GET | Get messages |
| `/conversations/:id/messages` | POST | Add message |
| `/conversations/:id/title` | PATCH | Update title |
| `/conversations/:id/archive` | PATCH | Archive |
| `/conversations/:id/unarchive` | PATCH | Unarchive |
| `/conversations/:id/pin` | PATCH | Pin |
| `/conversations/:id/unpin` | PATCH | Unpin |
## Chat Modes
The bot supports different ways to chat:
1. **Quick Chat** (`!chat`): Stateless, single message/response, no history
2. **Conversation Chat** (`!senden`): Stateful, maintains message history, context-aware
## Number-Based Reference System
The bot uses a number-based reference system for ease of use:
1. User runs `!gespraeche` or `!modelle` to get a list
2. Bot stores the list internally for the user
3. User can reference items by their list number
4. Numbers are valid until the user runs a new list command
This allows simple commands like:
- `!gespraech 3` - Select conversation #3
- `!archiv 1` - Archive conversation #1
- `!modell 2` - Select model #2

View file

@ -1,41 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files and install production dependencies only
COPY package.json ./
RUN npm install --omit=dev
# Copy built application from builder
COPY --from=builder /app/dist ./dist
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 3327
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3327/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,42 +0,0 @@
{
"name": "@mana-bots/matrix-chat-bot",
"version": "1.0.0",
"description": "Matrix bot for AI chat conversations",
"private": true,
"main": "dist/main.js",
"pnpm": {
"neverBuiltDependencies": [
"@matrix-org/matrix-sdk-crypto-nodejs"
],
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
}
},
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
},
"scripts": {
"prebuild": "rm -rf dist || true",
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/bot-services": "workspace:*",
"@manacore/matrix-bot-common": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"matrix-bot-sdk": "^0.7.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View file

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
import { BotModule } from './bot/bot.module';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
],
controllers: [HealthController],
providers: [createHealthProvider('matrix-chat-bot')],
})
export class AppModule {}

View file

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { ChatModule } from '../chat/chat.module';
import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services';
@Module({
imports: [
ChatModule,
SessionModule.forRoot({ storageMode: 'redis' }),
TranscriptionModule.register({
sttUrl: process.env.STT_URL || 'http://localhost:3020',
}),
CreditModule.forRoot(),
],
providers: [MatrixService],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -1,841 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
KeywordCommandDetector,
COMMON_KEYWORDS,
} from '@manacore/matrix-bot-common';
import { ChatService, Conversation } from '../chat/chat.service';
import {
SessionService,
TranscriptionService,
CreditService,
CreditErrorCode,
LOGIN_MESSAGES,
} from '@manacore/bot-services';
import { HELP_MESSAGE, BRANCH_ICONS } from '../config/configuration';
@Injectable()
export class MatrixService extends BaseMatrixService {
private readonly keywordDetector = new KeywordCommandDetector([
...COMMON_KEYWORDS,
{ keywords: ['gespraeche', 'conversations', 'liste', 'chats'], command: 'gespraeche' },
{ keywords: ['modelle', 'models', 'ki modelle', 'ai models'], command: 'modelle' },
{ keywords: ['neu', 'new', 'neues gespraech', 'new conversation'], command: 'neu' },
{ keywords: ['verlauf', 'history', 'nachrichten', 'messages'], command: 'verlauf' },
{ keywords: ['archiviert', 'archived', 'archiv liste'], command: 'archiviert' },
{ keywords: ['chat', 'fragen', 'ask', 'frage'], command: 'chat' },
]);
constructor(
configService: ConfigService,
private readonly transcriptionService: TranscriptionService,
private chatService: ChatService,
private sessionService: SessionService,
private creditService: CreditService
) {
super(configService);
}
protected override async handleAudioMessage(
roomId: string,
event: MatrixRoomEvent,
sender: string
): Promise<void> {
try {
const mxcUrl = event.content.url;
if (!mxcUrl) return;
const audioBuffer = await this.downloadMedia(mxcUrl);
const text = await this.transcriptionService.transcribe(audioBuffer);
if (!text) {
await this.sendReply(roomId, event, '❌ Sprachnachricht konnte nicht erkannt werden.');
return;
}
await this.sendMessage(roomId, `🎤 *"${text}"*`);
await this.handleTextMessage(roomId, event, text, sender);
} catch (error) {
this.logger.error(`Audio transcription error: ${error}`);
await this.sendReply(roomId, event, '❌ Fehler bei der Spracherkennung.');
}
}
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl:
this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
// Session data helper methods (wrapping the generic setSessionData/getSessionData)
private async getCurrentConversation(sender: string): Promise<string | null> {
return this.sessionService.getSessionData<string>(sender, 'currentConversationId');
}
private async setCurrentConversation(
sender: string,
conversationId: string | null
): Promise<void> {
await this.sessionService.setSessionData(sender, 'currentConversationId', conversationId);
}
private async getSelectedModel(sender: string): Promise<string | null> {
return this.sessionService.getSessionData<string>(sender, 'selectedModelId');
}
private async setSelectedModel(sender: string, modelId: string): Promise<void> {
await this.sessionService.setSessionData(sender, 'selectedModelId', modelId);
}
private async setConversationMapping(sender: string, ids: string[]): Promise<void> {
await this.sessionService.setSessionData(sender, 'conversationMapping', ids);
}
private async getConversationId(sender: string, number: number): Promise<string | null> {
const ids = await this.sessionService.getSessionData<string[]>(sender, 'conversationMapping');
if (!ids || number < 1 || number > ids.length) return null;
return ids[number - 1];
}
private async setModelMapping(sender: string, ids: string[]): Promise<void> {
await this.sessionService.setSessionData(sender, 'modelMapping', ids);
}
private async getModelId(sender: string, number: number): Promise<string | null> {
const ids = await this.sessionService.getSessionData<string[]>(sender, 'modelMapping');
if (!ids || number < 1 || number > ids.length) return null;
return ids[number - 1];
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
// Check for keyword commands first
const keywordCommand = this.keywordDetector.detect(message);
if (keywordCommand) {
message = `!${keywordCommand}`;
}
if (!message.startsWith('!')) return;
const [command, ...args] = message.slice(1).split(/\s+/);
const argString = args.join(' ');
let response: string;
switch (command.toLowerCase()) {
case 'help':
case 'hilfe':
response = HELP_MESSAGE;
break;
case 'status':
response = await this.handleStatus(sender);
break;
case 'chat':
case 'fragen':
case 'ask':
response = await this.handleQuickChat(sender, argString);
break;
case 'neu':
case 'new':
response = await this.handleNewConversation(sender, argString);
break;
case 'gespraeche':
case 'gespräche':
case 'conversations':
case 'liste':
response = await this.handleListConversations(sender);
break;
case 'gespraech':
case 'gespräch':
case 'conversation':
case 'select':
response = await this.handleSelectConversation(sender, args[0]);
break;
case 'senden':
case 'send':
case 's':
response = await this.handleSendMessage(sender, argString);
break;
case 'verlauf':
case 'history':
case 'nachrichten':
response = await this.handleShowHistory(sender, args[0]);
break;
case 'titel':
case 'title':
response = await this.handleUpdateTitle(sender, args[0], args.slice(1).join(' '));
break;
case 'archiv':
case 'archive':
response = await this.handleArchive(sender, args[0]);
break;
case 'archiviert':
case 'archived':
response = await this.handleListArchived(sender);
break;
case 'wiederherstellen':
case 'restore':
case 'unarchive':
response = await this.handleUnarchive(sender, args[0]);
break;
case 'pin':
response = await this.handlePin(sender, args[0]);
break;
case 'unpin':
response = await this.handleUnpin(sender, args[0]);
break;
case 'loeschen':
case 'löschen':
case 'delete':
response = await this.handleDelete(sender, args[0]);
break;
case 'modelle':
case 'models':
response = await this.handleListModels(sender);
break;
case 'modell':
case 'model':
response = await this.handleSelectModel(sender, args[0]);
break;
default:
response = `Unbekannter Befehl: ${command}\nNutze \`!help\` fuer eine Uebersicht.`;
}
await this.sendReply(roomId, event, response);
}
private async handleStatus(sender: string): Promise<string> {
const isLoggedIn = await this.sessionService.isLoggedIn(sender);
const email = await this.sessionService.getEmail(sender);
const token = await this.sessionService.getToken(sender);
const currentConv = await this.getCurrentConversation(sender);
const selectedModel = await this.getSelectedModel(sender);
// Get credit balance if logged in
let creditBalance = { balance: 0, hasCredits: false };
if (token) {
creditBalance = await this.creditService.getBalance(token);
}
const additionalInfo: Record<string, string> = {};
if (currentConv) {
additionalInfo['🗨️ Gespraech'] = `${currentConv.substring(0, 8)}...`;
}
if (selectedModel) {
additionalInfo['🧠 Modell'] = `${selectedModel.substring(0, 8)}...`;
}
if (!isLoggedIn) {
return `🤖 **Bot Status**\n\n❌ Nicht angemeldet.\n\n${LOGIN_MESSAGES.chat}`;
}
const statusMessage = this.creditService.formatStatusMessage(
email || 'Unbekannt',
creditBalance,
additionalInfo
);
return statusMessage.text;
}
// Quick chat (stateless)
private async handleQuickChat(sender: string, message: string): Promise<string> {
if (!message) {
return 'Verwendung: `!chat [deine nachricht]`';
}
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
// Get models to find default
const modelsResult = await this.chatService.getModels();
if (modelsResult.error || !modelsResult.data?.length) {
return 'Keine AI-Modelle verfuegbar.';
}
const selectedModelId = await this.getSelectedModel(sender);
const modelId =
selectedModelId || modelsResult.data.find((m) => m.isDefault)?.id || modelsResult.data[0].id;
const result = await this.chatService.createCompletion(
token,
[{ role: 'user', content: message }],
modelId
);
if (result.error) {
// Handle 402 Payment Required (insufficient credits)
if (result.statusCode === 402) {
const balance = await this.creditService.getBalance(token);
const errorMsg = this.creditService.formatInsufficientCreditsError(
2, // AI Chat costs ~2 credits
balance.balance,
'AI Chat'
);
return errorMsg.text;
}
return `Fehler: ${result.error}`;
}
let response = result.data!.content;
if (result.data!.usage) {
response += `\n\n_Tokens: ${result.data!.usage.total_tokens}_`;
}
return response;
}
// Conversation management
private async handleNewConversation(sender: string, title: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
// Get models to find default
const modelsResult = await this.chatService.getModels();
if (modelsResult.error || !modelsResult.data?.length) {
return 'Keine AI-Modelle verfuegbar.';
}
const selectedModelId = await this.getSelectedModel(sender);
const modelId =
selectedModelId || modelsResult.data.find((m) => m.isDefault)?.id || modelsResult.data[0].id;
const convTitle = title || `Matrix Chat ${new Date().toLocaleDateString('de-DE')}`;
const result = await this.chatService.createConversation(token, {
title: convTitle,
modelId,
conversationMode: 'free',
});
if (result.error) {
return `Fehler: ${result.error}`;
}
await this.setCurrentConversation(sender, result.data!.id);
return `Neues Gespraech erstellt: **${result.data!.title}**\nNutze \`!senden [nachricht]\` um zu chatten.`;
}
private async handleListConversations(sender: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
const result = await this.chatService.getConversations(token);
if (result.error) {
return `Fehler: ${result.error}`;
}
if (!result.data?.length) {
return 'Keine Gespraeche vorhanden. Erstelle eines mit `!neu [titel]`';
}
// Sort: pinned first, then by date
const sorted = result.data.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
// Store mapping
await this.setConversationMapping(
sender,
sorted.map((c) => c.id)
);
const currentId = await this.getCurrentConversation(sender);
let response = '**Deine Gespraeche:**\n\n';
sorted.forEach((conv, index) => {
const pin = conv.isPinned ? '📌 ' : '';
const current = conv.id === currentId ? ' ◀️' : '';
const date = new Date(conv.updatedAt).toLocaleDateString('de-DE');
response += `${index + 1}. ${pin}**${conv.title}**${current}\n _${date}_\n`;
});
response += '\nNutze `!gespraech [nr]` zum Auswaehlen.';
return response;
}
private async handleSelectConversation(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
// Show current conversation
const currentId = await this.getCurrentConversation(sender);
if (!currentId) {
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und dann `!gespraech [nr]`';
}
const result = await this.chatService.getConversation(token, currentId);
if (result.error) {
return `Fehler: ${result.error}`;
}
return this.formatConversationDetails(result.data!);
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
const result = await this.chatService.getConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
await this.setCurrentConversation(sender, conversationId);
return `Gespraech ausgewaehlt: **${result.data!.title}**\n\n${this.formatConversationDetails(result.data!)}`;
}
private formatConversationDetails(conv: Conversation): string {
const pin = conv.isPinned ? '📌 Angepinnt' : '';
const created = new Date(conv.createdAt).toLocaleDateString('de-DE');
const updated = new Date(conv.updatedAt).toLocaleDateString('de-DE');
return `**${conv.title}** ${pin}
- Modus: ${conv.conversationMode}
- Dokument-Modus: ${conv.documentMode ? 'Ja' : 'Nein'}
- Erstellt: ${created}
- Aktualisiert: ${updated}
Nutze \`!senden [nachricht]\` um zu chatten oder \`!verlauf\` fuer den Nachrichtenverlauf.`;
}
private async handleSendMessage(sender: string, message: string): Promise<string> {
if (!message) {
return 'Verwendung: `!senden [deine nachricht]`';
}
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
const conversationId = await this.getCurrentConversation(sender);
if (!conversationId) {
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und `!gespraech [nr]` oder `!neu [titel]`';
}
// Add user message
const userMsgResult = await this.chatService.addMessage(token, conversationId, message, 'user');
if (userMsgResult.error) {
return `Fehler: ${userMsgResult.error}`;
}
// Get conversation for model ID
const convResult = await this.chatService.getConversation(token, conversationId);
if (convResult.error) {
return `Fehler: ${convResult.error}`;
}
// Get message history for context
const historyResult = await this.chatService.getMessages(token, conversationId);
const messages = (historyResult.data || []).map((m) => ({
role: m.sender as 'user' | 'assistant' | 'system',
content: m.messageText,
}));
// Get AI response
const completionResult = await this.chatService.createCompletion(
token,
messages,
convResult.data!.modelId
);
if (completionResult.error) {
// Handle 402 Payment Required (insufficient credits)
if (completionResult.statusCode === 402) {
const balance = await this.creditService.getBalance(token);
const errorMsg = this.creditService.formatInsufficientCreditsError(
2, // AI Chat costs ~2 credits
balance.balance,
'AI Chat'
);
return errorMsg.text;
}
return `Fehler bei AI-Antwort: ${completionResult.error}`;
}
// Save assistant response
await this.chatService.addMessage(
token,
conversationId,
completionResult.data!.content,
'assistant'
);
let response = completionResult.data!.content;
if (completionResult.data!.usage) {
response += `\n\n_Tokens: ${completionResult.data!.usage.total_tokens}_`;
}
return response;
}
private async handleShowHistory(sender: string, numberStr?: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
let conversationId = await this.getCurrentConversation(sender);
if (numberStr) {
const number = parseInt(numberStr, 10);
if (!isNaN(number)) {
const id = await this.getConversationId(sender, number);
if (id) conversationId = id;
}
}
if (!conversationId) {
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und `!gespraech [nr]`';
}
const result = await this.chatService.getMessages(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
if (!result.data?.length) {
return 'Noch keine Nachrichten in diesem Gespraech.';
}
let response = '**Nachrichtenverlauf:**\n\n';
const recentMessages = result.data.slice(-10); // Last 10 messages
recentMessages.forEach((msg) => {
const icon = msg.sender === 'user' ? '👤' : msg.sender === 'assistant' ? '🤖' : '⚙️';
const text =
msg.messageText.length > 200 ? msg.messageText.substring(0, 200) + '...' : msg.messageText;
response += `${icon} **${msg.sender}:**\n${text}\n\n`;
});
if (result.data.length > 10) {
response += `_...und ${result.data.length - 10} weitere Nachrichten_`;
}
return response;
}
// Conversation management actions
private async handleUpdateTitle(
sender: string,
numberStr: string,
title: string
): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr || !title) {
return 'Verwendung: `!titel [nr] [neuer titel]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
const result = await this.chatService.updateTitle(token, conversationId, title);
if (result.error) {
return `Fehler: ${result.error}`;
}
return `Titel geaendert zu: **${result.data!.title}**`;
}
private async handleArchive(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
return 'Verwendung: `!archiv [nr]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
const result = await this.chatService.archiveConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
return `Gespraech **${result.data!.title}** archiviert.`;
}
private async handleListArchived(sender: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
const result = await this.chatService.getArchivedConversations(token);
if (result.error) {
return `Fehler: ${result.error}`;
}
if (!result.data?.length) {
return 'Keine archivierten Gespraeche.';
}
// Store mapping for restore
await this.setConversationMapping(
sender,
result.data.map((c) => c.id)
);
let response = '**Archivierte Gespraeche:**\n\n';
result.data.forEach((conv, index) => {
const date = new Date(conv.updatedAt).toLocaleDateString('de-DE');
response += `${index + 1}. **${conv.title}**\n _${date}_\n`;
});
response += '\nNutze `!wiederherstellen [nr]` zum Wiederherstellen.';
return response;
}
private async handleUnarchive(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
return 'Verwendung: `!wiederherstellen [nr]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!archiviert` fuer eine aktuelle Liste.';
}
const result = await this.chatService.unarchiveConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
return `Gespraech **${result.data!.title}** wiederhergestellt.`;
}
private async handlePin(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
return 'Verwendung: `!pin [nr]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
const result = await this.chatService.pinConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
return `Gespraech **${result.data!.title}** angepinnt. 📌`;
}
private async handleUnpin(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
return 'Verwendung: `!unpin [nr]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
const result = await this.chatService.unpinConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
return `Pin fuer **${result.data!.title}** entfernt.`;
}
private async handleDelete(sender: string, numberStr: string): Promise<string> {
const token = await this.sessionService.getToken(sender);
if (!token) {
return LOGIN_MESSAGES.chat;
}
if (!numberStr) {
return 'Verwendung: `!loeschen [nr]`';
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const conversationId = await this.getConversationId(sender, number);
if (!conversationId) {
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
}
// Get title before deletion
const convResult = await this.chatService.getConversation(token, conversationId);
const title = convResult.data?.title || 'Gespraech';
const result = await this.chatService.deleteConversation(token, conversationId);
if (result.error) {
return `Fehler: ${result.error}`;
}
// Clear current conversation if it was the deleted one
if ((await this.getCurrentConversation(sender)) === conversationId) {
await this.setCurrentConversation(sender, null);
}
return `Gespraech **${title}** geloescht.`;
}
// Model management
private async handleListModels(sender: string): Promise<string> {
const result = await this.chatService.getModels();
if (result.error) {
return `Fehler: ${result.error}`;
}
if (!result.data?.length) {
return 'Keine AI-Modelle verfuegbar.';
}
const activeModels = result.data.filter((m) => m.isActive);
// Store mapping
await this.setModelMapping(
sender,
activeModels.map((m) => m.id)
);
const selectedModelId = await this.getSelectedModel(sender);
let response = '**Verfuegbare AI-Modelle:**\n\n';
activeModels.forEach((model, index) => {
const icon = BRANCH_ICONS[model.provider] || BRANCH_ICONS.default;
const isDefault = model.isDefault ? ' (Standard)' : '';
const selected = model.id === selectedModelId ? ' ◀️' : '';
const desc = model.description ? `\n _${model.description}_` : '';
response += `${index + 1}. ${icon} **${model.name}**${isDefault}${selected}${desc}\n`;
});
response += '\nNutze `!modell [nr]` zum Auswaehlen.';
return response;
}
private async handleSelectModel(sender: string, numberStr: string): Promise<string> {
if (!numberStr) {
const selectedModelId = await this.getSelectedModel(sender);
if (!selectedModelId) {
return 'Kein Modell ausgewaehlt (Standard wird verwendet). Nutze `!modelle` und `!modell [nr]`';
}
const result = await this.chatService.getModel(selectedModelId);
if (result.error) {
return 'Ausgewaehltes Modell nicht gefunden.';
}
const icon = BRANCH_ICONS[result.data!.provider] || BRANCH_ICONS.default;
return `Aktuelles Modell: ${icon} **${result.data!.name}**`;
}
const number = parseInt(numberStr, 10);
if (isNaN(number)) {
return 'Bitte eine gueltige Nummer angeben.';
}
const modelId = await this.getModelId(sender, number);
if (!modelId) {
return 'Ungueltige Nummer. Nutze `!modelle` fuer eine aktuelle Liste.';
}
const result = await this.chatService.getModel(modelId);
if (result.error) {
return `Fehler: ${result.error}`;
}
await this.setSelectedModel(sender, modelId);
const icon = BRANCH_ICONS[result.data!.provider] || BRANCH_ICONS.default;
return `Modell gewaehlt: ${icon} **${result.data!.name}**\nWird fuer neue Gespraeche und Quick-Chat verwendet.`;
}
}

View file

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { ChatService } from './chat.service';
@Module({
providers: [ChatService],
exports: [ChatService],
})
export class ChatModule {}

View file

@ -1,223 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface Model {
id: string;
name: string;
description?: string;
provider: string;
isActive: boolean;
isDefault: boolean;
}
export interface Conversation {
id: string;
userId: string;
modelId: string;
title: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
}
export interface Message {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
}
export interface ChatCompletionResponse {
content: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
private baseUrl: string;
private apiPrefix: string;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('chat.url') || 'http://localhost:3002';
this.apiPrefix = this.configService.get<string>('chat.apiPrefix') || '';
}
private getUrl(path: string): string {
return `${this.baseUrl}${this.apiPrefix}${path}`;
}
private async request<T>(
path: string,
token: string,
options: RequestInit = {}
): Promise<{ data?: T; error?: string; statusCode?: number }> {
try {
const response = await fetch(this.getUrl(path), {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
error: errorData.message || `HTTP ${response.status}`,
statusCode: response.status,
};
}
const data = await response.json();
return { data };
} catch (error) {
this.logger.error(`Request failed: ${path}`, error);
return { error: 'Verbindung zum Chat-Server fehlgeschlagen' };
}
}
// Models (public endpoints)
async getModels(): Promise<{ data?: Model[]; error?: string }> {
try {
const response = await fetch(this.getUrl('/models'));
if (!response.ok) {
return { error: `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
this.logger.error('Failed to fetch models', error);
return { error: 'Verbindung zum Chat-Server fehlgeschlagen' };
}
}
async getModel(id: string): Promise<{ data?: Model; error?: string }> {
try {
const response = await fetch(this.getUrl(`/models/${id}`));
if (!response.ok) {
return { error: `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
this.logger.error(`Failed to fetch model ${id}`, error);
return { error: 'Modell nicht gefunden' };
}
}
// Chat Completions
async createCompletion(
token: string,
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
modelId: string,
options?: { temperature?: number; maxTokens?: number }
): Promise<{ data?: ChatCompletionResponse; error?: string; statusCode?: number }> {
return this.request<ChatCompletionResponse>('/chat/completions', token, {
method: 'POST',
body: JSON.stringify({
messages,
modelId,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
}),
});
}
// Conversations
async getConversations(
token: string,
spaceId?: string
): Promise<{ data?: Conversation[]; error?: string }> {
const query = spaceId ? `?spaceId=${spaceId}` : '';
return this.request<Conversation[]>(`/conversations${query}`, token);
}
async getArchivedConversations(token: string): Promise<{ data?: Conversation[]; error?: string }> {
return this.request<Conversation[]>('/conversations/archived', token);
}
async getConversation(token: string, id: string): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${id}`, token);
}
async getMessages(token: string, conversationId: string): Promise<{ data?: Message[]; error?: string }> {
return this.request<Message[]>(`/conversations/${conversationId}/messages`, token);
}
async createConversation(
token: string,
data: {
title: string;
modelId: string;
conversationMode?: 'free' | 'guided' | 'template';
}
): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>('/conversations', token, {
method: 'POST',
body: JSON.stringify(data),
});
}
async addMessage(
token: string,
conversationId: string,
messageText: string,
sender: 'user' | 'assistant' = 'user'
): Promise<{ data?: Message; error?: string }> {
return this.request<Message>(`/conversations/${conversationId}/messages`, token, {
method: 'POST',
body: JSON.stringify({ messageText, sender }),
});
}
async updateTitle(
token: string,
conversationId: string,
title: string
): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${conversationId}/title`, token, {
method: 'PATCH',
body: JSON.stringify({ title }),
});
}
async archiveConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${conversationId}/archive`, token, {
method: 'PATCH',
});
}
async unarchiveConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${conversationId}/unarchive`, token, {
method: 'PATCH',
});
}
async pinConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${conversationId}/pin`, token, {
method: 'PATCH',
});
}
async unpinConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
return this.request<Conversation>(`/conversations/${conversationId}/unpin`, token, {
method: 'PATCH',
});
}
async deleteConversation(token: string, conversationId: string): Promise<{ error?: string }> {
return this.request(`/conversations/${conversationId}`, token, {
method: 'DELETE',
});
}
}

View file

@ -1,65 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3327', 10),
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN,
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [],
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
chat: {
url: process.env.CHAT_BACKEND_URL || 'http://localhost:3002',
apiPrefix: process.env.CHAT_API_PREFIX || '',
},
auth: {
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
});
export const HELP_MESSAGE = `**AI Chat Bot - Hilfe**
**Status:**
- \`!status\` - Bot-Status anzeigen
**Schnell-Chat:**
- \`!chat [nachricht]\` - Schnelle AI-Antwort (ohne Verlauf)
- \`!fragen [nachricht]\` - Alias fuer !chat
**Gespraeche:**
- \`!neu [titel]\` - Neues Gespraech starten
- \`!gespraeche\` - Alle Gespraeche auflisten
- \`!gespraech [nr]\` - Gespraech auswaehlen/anzeigen
- \`!senden [nachricht]\` - Nachricht im aktuellen Gespraech senden
- \`!verlauf\` - Nachrichtenverlauf anzeigen
**Gespraechsverwaltung:**
- \`!titel [nr] [neuer titel]\` - Titel aendern
- \`!archiv [nr]\` - Gespraech archivieren
- \`!archiviert\` - Archivierte Gespraeche anzeigen
- \`!wiederherstellen [nr]\` - Aus Archiv wiederherstellen
- \`!pin [nr]\` - Gespraech anpinnen
- \`!unpin [nr]\` - Pin entfernen
- \`!loeschen [nr]\` - Gespraech loeschen
**Modelle:**
- \`!modelle\` - Verfuegbare AI-Modelle auflisten
- \`!modell [nr]\` - Modell fuer neues Gespraech waehlen
**Beispiele:**
\`\`\`
!chat Was ist die Hauptstadt von Frankreich?
!neu Programmierung
!senden Erklaere mir Python Listen
!gespraeche
!gespraech 1
!verlauf
!modelle
\`\`\`
`;
export const BRANCH_ICONS: Record<string, string> = {
ollama: '🏠',
openrouter: '☁️',
openai: '🤖',
anthropic: '🧠',
default: '🔮',
};

View file

@ -1,10 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3327;
await app.listen(port);
console.log(`Matrix Chat Bot running on port ${port}`);
}
bootstrap();

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}

View file

@ -1,6 +0,0 @@
node_modules
dist
.git
*.log
.env*
data

View file

@ -1,18 +0,0 @@
# Server
PORT=3317
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token
MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Clock Backend API
CLOCK_API_URL=http://localhost:3017/api/v1
CLOCK_API_TOKEN=
# Speech-to-Text (mana-stt service)
STT_URL=http://localhost:3020
# Widget (public URL for embedding in Matrix clients)
WIDGET_PUBLIC_URL=http://localhost:3317/widget

View file

@ -1,185 +0,0 @@
# Matrix Clock Bot - Claude Code Guidelines
## Overview
Matrix Clock Bot provides time tracking functionality via Matrix chat. Users can create timers, set alarms, and manage world clocks through text commands or voice notes.
## Tech Stack
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Backend**: Clock API (port 3017)
- **STT**: mana-stt service (port 3020)
## Commands
```bash
# Development
pnpm install
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/matrix-clock-bot/
├── src/
│ ├── main.ts # Application entry point (port 3317)
│ ├── app.module.ts # Root module
│ ├── config/
│ │ └── configuration.ts # Configuration & help messages
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── matrix.service.ts # Matrix client & command handlers
│ ├── clock/
│ │ ├── clock.module.ts
│ │ └── clock.service.ts # Clock API client
│ └── widget/
│ ├── widget.module.ts
│ └── widget.controller.ts # Timer widget for Element sidebar
├── Dockerfile
└── package.json
```
## Bot Commands
### Timer Commands
| Command | Description |
|---------|-------------|
| `!timer 25m` | Create & start 25-minute timer |
| `!timer 1h30m` | Create 1.5 hour timer |
| `!timer 25m Pomodoro` | Timer with label |
| `!stop` | Pause running timer |
| `!resume` | Resume paused timer |
| `!reset` | Reset timer to start |
| `!status` | Show current timer status |
| `!timers` | List all timers |
| `!widget` | Add timer widget to room sidebar |
### Alarm Commands
| Command | Description |
|---------|-------------|
| `!alarm 07:30` | Set alarm for 7:30 |
| `!alarm 7 Uhr 30` | German time format |
| `!alarm 06:00 Aufstehen!` | Alarm with label |
| `!alarms` | List all alarms |
### World Clock Commands
| Command | Description |
|---------|-------------|
| `!zeit` / `!time` | Current time + world clocks |
| `!weltuhr Berlin` | Add world clock |
| `!weltuhren` | List world clocks |
### Natural Language & Voice
The bot understands natural language:
- "Timer 25 Minuten"
- "Wecker um 7 Uhr"
- "Stop"
- "Status"
Voice notes are transcribed and parsed as commands.
## Environment Variables
```env
# Server
PORT=3317
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Clock Backend API
CLOCK_API_URL=http://localhost:3017/api/v1
CLOCK_API_TOKEN=
# Speech-to-Text
STT_URL=http://localhost:3020
# Widget (public URL for embedding in Matrix clients)
WIDGET_PUBLIC_URL=http://localhost:3317/widget
```
## Clock API Endpoints Used
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/timers` | GET | List all timers |
| `/timers` | POST | Create timer |
| `/timers/:id/start` | POST | Start timer |
| `/timers/:id/pause` | POST | Pause timer |
| `/timers/:id/reset` | POST | Reset timer |
| `/alarms` | GET | List alarms |
| `/alarms` | POST | Create alarm |
| `/alarms/:id/toggle` | PATCH | Toggle alarm |
| `/world-clocks` | GET | List world clocks |
| `/world-clocks` | POST | Add world clock |
| `/timezones/search` | GET | Search timezones (public) |
## Widget
The bot provides a timer widget that can be embedded in Matrix/Element rooms.
### Widget Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /widget` | HTML page with timer display |
| `GET /widget/api/status?userId=X` | Timer status JSON |
| `GET /widget/api/control?userId=X&action=Y` | Control timer (start/pause/reset) |
### Adding Widget
1. Use `!widget` command - bot adds widget automatically
2. Or manually: `/addwidget <WIDGET_PUBLIC_URL>`
### Widget Features
- Live timer progress bar with countdown
- Start/Pause/Reset controls
- Auto-refresh every 10 seconds with local countdown
- Dark theme matching Element
## Docker
```bash
# Build
docker build -f services/matrix-clock-bot/Dockerfile -t matrix-clock-bot services/matrix-clock-bot
# Run
docker run -p 3318:3318 \
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e CLOCK_API_URL=http://clock-backend:3017/api/v1 \
-e STT_URL=http://mana-stt:3020 \
-v matrix-clock-bot-data:/app/data \
matrix-clock-bot
```
## Health Check
```bash
curl http://localhost:3318/health
```
## Authentication
Currently uses a demo token (`CLOCK_API_TOKEN`) for development. Production should implement proper user authentication flow:
1. User sends `!login` command
2. Bot initiates OAuth/auth flow with mana-core-auth
3. User token stored per Matrix user ID
4. Token used for all Clock API calls

View file

@ -1,72 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-slim AS builder
WORKDIR /app
# Enable pnpm via corepack
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy shared packages that this bot depends on
COPY packages/bot-services ./packages/bot-services
COPY packages/matrix-bot-common ./packages/matrix-bot-common
# Copy this bot
COPY services/matrix-clock-bot ./services/matrix-clock-bot
# Install all dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
RUN pnpm --filter @manacore/bot-services build
RUN pnpm --filter @manacore/matrix-bot-common build
# Build the bot
RUN pnpm --filter @manacore/matrix-clock-bot build
# Production stage
FROM node:20-slim AS runner
WORKDIR /app
# Install wget for health checks and enable pnpm
RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy built shared packages
COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist
COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/
COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist
COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/
# Copy built bot
COPY --from=builder /app/services/matrix-clock-bot/dist ./services/matrix-clock-bot/dist
COPY --from=builder /app/services/matrix-clock-bot/package.json ./services/matrix-clock-bot/
# Install production dependencies only
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts
# Create data directory
RUN mkdir -p /app/data
# Create non-root user
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 -g nodejs nestjs && \
chown -R nestjs:nodejs /app/data
USER nestjs
WORKDIR /app/services/matrix-clock-bot
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4018/health || exit 1
EXPOSE 4018
CMD ["node", "dist/main.js"]

View file

@ -1,5 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View file

@ -1,43 +0,0 @@
{
"name": "@manacore/matrix-clock-bot",
"version": "1.0.0",
"description": "Matrix bot for time tracking with Clock app",
"private": true,
"pnpm": {
"neverBuiltDependencies": [
"@matrix-org/matrix-sdk-crypto-nodejs"
],
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
}
},
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
},
"scripts": {
"prebuild": "rm -rf dist || true",
"build": "tsc -p tsconfig.build.json",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/bot-services": "workspace:*",
"@manacore/matrix-bot-common": "workspace:*",
"@nestjs/common": "^10.4.17",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.17",
"@nestjs/platform-express": "^10.4.17",
"matrix-bot-sdk": "^0.7.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@types/express": "^5.0.6",
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

Some files were not shown because too many files have changed in this diff Show more