mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 03:16:44 +02:00
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:
parent
ce51fd5fe2
commit
819568c3df
503 changed files with 9927 additions and 47044 deletions
1
services/mana-api-gateway-go/.gitignore
vendored
Normal file
1
services/mana-api-gateway-go/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist/
|
||||
53
services/mana-api-gateway-go/CLAUDE.md
Normal file
53
services/mana-api-gateway-go/CLAUDE.md
Normal 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
|
||||
23
services/mana-api-gateway-go/Dockerfile
Normal file
23
services/mana-api-gateway-go/Dockerfile
Normal 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"]
|
||||
138
services/mana-api-gateway-go/cmd/server/main.go
Normal file
138
services/mana-api-gateway-go/cmd/server/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
services/mana-api-gateway-go/go.mod
Normal file
21
services/mana-api-gateway-go/go.mod
Normal 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
|
||||
)
|
||||
46
services/mana-api-gateway-go/go.sum
Normal file
46
services/mana-api-gateway-go/go.sum
Normal 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=
|
||||
93
services/mana-api-gateway-go/internal/config/config.go
Normal file
93
services/mana-api-gateway-go/internal/config/config.go
Normal 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
|
||||
}
|
||||
119
services/mana-api-gateway-go/internal/db/db.go
Normal file
119
services/mana-api-gateway-go/internal/db/db.go
Normal 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()
|
||||
}
|
||||
132
services/mana-api-gateway-go/internal/handler/apikeys.go
Normal file
132
services/mana-api-gateway-go/internal/handler/apikeys.go
Normal 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)
|
||||
}
|
||||
69
services/mana-api-gateway-go/internal/handler/health.go
Normal file
69
services/mana-api-gateway-go/internal/handler/health.go
Normal 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())
|
||||
}
|
||||
106
services/mana-api-gateway-go/internal/middleware/apikey.go
Normal file
106
services/mana-api-gateway-go/internal/middleware/apikey.go
Normal 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)
|
||||
}
|
||||
101
services/mana-api-gateway-go/internal/middleware/jwt.go
Normal file
101
services/mana-api-gateway-go/internal/middleware/jwt.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
157
services/mana-api-gateway-go/internal/proxy/proxy.go
Normal file
157
services/mana-api-gateway-go/internal/proxy/proxy.go
Normal 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)
|
||||
}
|
||||
256
services/mana-api-gateway-go/internal/service/apikeys.go
Normal file
256
services/mana-api-gateway-go/internal/service/apikeys.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
114
services/mana-api-gateway-go/internal/service/usage.go
Normal file
114
services/mana-api-gateway-go/internal/service/usage.go
Normal 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
|
||||
}
|
||||
11
services/mana-api-gateway-go/package.json
Normal file
11
services/mana-api-gateway-go/package.json
Normal 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
3
services/mana-matrix-bot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
data/
|
||||
*.json.bak
|
||||
67
services/mana-matrix-bot/CLAUDE.md
Normal file
67
services/mana-matrix-bot/CLAUDE.md
Normal 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
|
||||
28
services/mana-matrix-bot/Dockerfile
Normal file
28
services/mana-matrix-bot/Dockerfile
Normal 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"]
|
||||
77
services/mana-matrix-bot/cmd/server/main.go
Normal file
77
services/mana-matrix-bot/cmd/server/main.go
Normal 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")
|
||||
}
|
||||
28
services/mana-matrix-bot/go.mod
Normal file
28
services/mana-matrix-bot/go.mod
Normal 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
|
||||
)
|
||||
66
services/mana-matrix-bot/go.sum
Normal file
66
services/mana-matrix-bot/go.sum
Normal 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=
|
||||
219
services/mana-matrix-bot/internal/config/config.go
Normal file
219
services/mana-matrix-bot/internal/config/config.go
Normal 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
|
||||
}
|
||||
241
services/mana-matrix-bot/internal/matrix/client.go
Normal file
241
services/mana-matrix-bot/internal/matrix/client.go
Normal 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
|
||||
}
|
||||
63
services/mana-matrix-bot/internal/matrix/markdown.go
Normal file
63
services/mana-matrix-bot/internal/matrix/markdown.go
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
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()
|
||||
}
|
||||
64
services/mana-matrix-bot/internal/matrix/markdown_test.go
Normal file
64
services/mana-matrix-bot/internal/matrix/markdown_test.go
Normal 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>", "<script>"},
|
||||
{`"quotes"`, ""quotes""},
|
||||
{"a & b", "a & b"},
|
||||
{"it's", "it'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)
|
||||
}
|
||||
}
|
||||
75
services/mana-matrix-bot/internal/matrix/types.go
Normal file
75
services/mana-matrix-bot/internal/matrix/types.go
Normal 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)
|
||||
}
|
||||
27
services/mana-matrix-bot/internal/matrix/types_test.go
Normal file
27
services/mana-matrix-bot/internal/matrix/types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
services/mana-matrix-bot/internal/plugin/command.go
Normal file
43
services/mana-matrix-bot/internal/plugin/command.go
Normal 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
|
||||
}
|
||||
87
services/mana-matrix-bot/internal/plugin/keyword.go
Normal file
87
services/mana-matrix-bot/internal/plugin/keyword.go
Normal 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"},
|
||||
}
|
||||
78
services/mana-matrix-bot/internal/plugin/keyword_test.go
Normal file
78
services/mana-matrix-bot/internal/plugin/keyword_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
services/mana-matrix-bot/internal/plugin/plugin.go
Normal file
99
services/mana-matrix-bot/internal/plugin/plugin.go
Normal 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
|
||||
}
|
||||
29
services/mana-matrix-bot/internal/plugin/registry.go
Normal file
29
services/mana-matrix-bot/internal/plugin/registry.go
Normal 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
|
||||
}
|
||||
410
services/mana-matrix-bot/internal/plugins/calendar/calendar.go
Normal file
410
services/mana-matrix-bot/internal/plugins/calendar/calendar.go
Normal 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
|
||||
}
|
||||
65
services/mana-matrix-bot/internal/plugins/chat/chat.go
Normal file
65
services/mana-matrix-bot/internal/plugins/chat/chat.go
Normal 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
|
||||
}
|
||||
441
services/mana-matrix-bot/internal/plugins/clock/clock.go
Normal file
441
services/mana-matrix-bot/internal/plugins/clock/clock.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
543
services/mana-matrix-bot/internal/plugins/contacts/contacts.go
Normal file
543
services/mana-matrix-bot/internal/plugins/contacts/contacts.go
Normal 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
|
||||
}
|
||||
}
|
||||
586
services/mana-matrix-bot/internal/plugins/gateway/gateway.go
Normal file
586
services/mana-matrix-bot/internal/plugins/gateway/gateway.go
Normal 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
|
||||
}
|
||||
120
services/mana-matrix-bot/internal/plugins/gateway/ollama.go
Normal file
120
services/mana-matrix-bot/internal/plugins/gateway/ollama.go
Normal 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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
462
services/mana-matrix-bot/internal/plugins/ollama/ollama.go
Normal file
462
services/mana-matrix-bot/internal/plugins/ollama/ollama.go
Normal 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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
74
services/mana-matrix-bot/internal/plugins/picture/picture.go
Normal file
74
services/mana-matrix-bot/internal/plugins/picture/picture.go
Normal 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
|
||||
}
|
||||
543
services/mana-matrix-bot/internal/plugins/planta/planta.go
Normal file
543
services/mana-matrix-bot/internal/plugins/planta/planta.go
Normal 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
|
||||
}
|
||||
}
|
||||
66
services/mana-matrix-bot/internal/plugins/presi/presi.go
Normal file
66
services/mana-matrix-bot/internal/plugins/presi/presi.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
79
services/mana-matrix-bot/internal/plugins/stats/stats.go
Normal file
79
services/mana-matrix-bot/internal/plugins/stats/stats.go
Normal 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
|
||||
}
|
||||
66
services/mana-matrix-bot/internal/plugins/storage/storage.go
Normal file
66
services/mana-matrix-bot/internal/plugins/storage/storage.go
Normal 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
|
||||
}
|
||||
200
services/mana-matrix-bot/internal/plugins/stt/stt.go
Normal file
200
services/mana-matrix-bot/internal/plugins/stt/stt.go
Normal 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
|
||||
}
|
||||
553
services/mana-matrix-bot/internal/plugins/todo/todo.go
Normal file
553
services/mana-matrix-bot/internal/plugins/todo/todo.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
87
services/mana-matrix-bot/internal/plugins/todo/todo_test.go
Normal file
87
services/mana-matrix-bot/internal/plugins/todo/todo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
253
services/mana-matrix-bot/internal/plugins/tts/tts.go
Normal file
253
services/mana-matrix-bot/internal/plugins/tts/tts.go
Normal 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
|
||||
}
|
||||
392
services/mana-matrix-bot/internal/plugins/zitare/zitare.go
Normal file
392
services/mana-matrix-bot/internal/plugins/zitare/zitare.go
Normal 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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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, "es); 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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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)
|
||||
}
|
||||
66
services/mana-matrix-bot/internal/runtime/health.go
Normal file
66
services/mana-matrix-bot/internal/runtime/health.go
Normal 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))
|
||||
}
|
||||
387
services/mana-matrix-bot/internal/runtime/runtime.go
Normal file
387
services/mana-matrix-bot/internal/runtime/runtime.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
services/mana-matrix-bot/internal/services/auth.go
Normal file
52
services/mana-matrix-bot/internal/services/auth.go
Normal 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)
|
||||
}
|
||||
154
services/mana-matrix-bot/internal/services/backend.go
Normal file
154
services/mana-matrix-bot/internal/services/backend.go
Normal 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
|
||||
}
|
||||
62
services/mana-matrix-bot/internal/services/credit.go
Normal file
62
services/mana-matrix-bot/internal/services/credit.go
Normal 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)
|
||||
}
|
||||
118
services/mana-matrix-bot/internal/services/voice.go
Normal file
118
services/mana-matrix-bot/internal/services/voice.go
Normal 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)
|
||||
}
|
||||
174
services/mana-matrix-bot/internal/session/redis.go
Normal file
174
services/mana-matrix-bot/internal/session/redis.go
Normal 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()
|
||||
}
|
||||
93
services/mana-matrix-bot/internal/session/session.go
Normal file
93
services/mana-matrix-bot/internal/session/session.go
Normal 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
|
||||
}
|
||||
12
services/mana-matrix-bot/package.json
Normal file
12
services/mana-matrix-bot/package.json
Normal 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 ../../"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env*
|
||||
data
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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}**`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!`;
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
5
services/matrix-chat-bot/.gitignore
vendored
5
services/matrix-chat-bot/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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.`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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: '🔮',
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env*
|
||||
data
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue